feat(bisaya-course): refine phase 4 didactics and enhance course content generation
All checks were successful
Deploy to production / deploy (push) Successful in 5m19s

- Corrected grammatical errors and improved the phrasing in the BISAYA_PHASE4_DIDACTICS, ensuring clarity and accuracy in the learning materials.
- Updated the course content generation script to include lessons from phase 5, enhancing the overall structure and flow of the course.
- Introduced a new vocabulary course content synchronization process, improving the integration of vocabulary resources across different modules.
- Enhanced the VocabService to dynamically adjust temperature settings based on the mode, optimizing response generation for different contexts.
- Added new localized titles and vocabulary entries in multiple languages, enriching the learning experience for users.
This commit is contained in:
Torsten Schulz (local)
2026-04-17 16:00:41 +02:00
parent 5c315c477f
commit 71d5922409
18 changed files with 410 additions and 86 deletions

View File

@@ -63,8 +63,8 @@ export default {
falukantDisplayName() {
const d = this.falukantData;
if (!d) return this.$t('widgets.falukant.emptyValue');
const titleKey = d.titleLabelTr;
const gender = d.gender;
const gender = this._normalizeGenderKey(d.gender);
const titleKey = this._normalizeTitleKey(d.titleLabelTr, gender);
const nameWithoutTitle = d.nameWithoutTitle ?? d.characterName;
if (titleKey && gender) {
const key = `falukant.titles.${gender}.${titleKey}`;
@@ -74,7 +74,7 @@ export default {
return d.characterName || nameWithoutTitle || this.$t('widgets.falukant.emptyValue');
},
falukantGenderLabel() {
const g = this.falukantData?.gender;
const g = this._normalizeGenderKey(this.falukantData?.gender);
if (g == null || g === '') return this.$t('widgets.falukant.emptyValue');
// Altersabhängige, (auf Wunsch) altertümlichere Bezeichnungen
@@ -143,6 +143,45 @@ export default {
// Fallback, falls Konfig kaputt ist
return 'adult';
},
_normalizeGenderKey(rawGender) {
const g = String(rawGender || '').trim();
if (!g) return '';
const lower = g.toLowerCase();
const direct = new Set(['male', 'female', 'transmale', 'transfemale', 'nonbinary']);
if (direct.has(lower)) return lower;
// Legacy/locale labels that can appear from backend data drifts.
if (['mann', 'männlich', 'lalaki'].includes(lower)) return 'male';
if (['frau', 'weiblich', 'babaye'].includes(lower)) return 'female';
if (['trans-mann', 'transmann'].includes(lower)) return 'transmale';
if (['trans-frau', 'transfrau'].includes(lower)) return 'transfemale';
if (['non-binary', 'nichtbinär', 'dili binaryo'].includes(lower)) return 'nonbinary';
return lower;
},
_normalizeTitleKey(rawTitle, genderKey) {
const title = String(rawTitle || '').trim();
if (!title) return '';
const known = new Set([
'noncivil', 'civil', 'sir', 'townlord', 'by', 'landlord',
'knight', 'baron', 'count', 'palsgrave', 'margrave', 'landgrave',
'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke',
'prince-regent', 'king'
]);
if (known.has(title)) return title;
const candidates = [];
const locale = this.$i18n?.locale;
if (locale) candidates.push(locale);
if (!candidates.includes('de')) candidates.push('de');
if (!candidates.includes('en')) candidates.push('en');
for (const loc of candidates) {
const entries = this.$i18n?.messages?.[loc]?.falukant?.titles?.[genderKey];
if (!entries || typeof entries !== 'object') continue;
const found = Object.entries(entries).find(([, value]) => String(value || '').trim().toLowerCase() === title.toLowerCase());
if (found?.[0]) return found[0];
}
return title;
},
formatMoney(value) {
const n = Number(value);
if (Number.isNaN(n)) return this.$t('widgets.falukant.emptyValue');

View File

@@ -9,7 +9,7 @@
<ul v-else class="vocab-courses-widget__list">
<li v-for="c in courses" :key="c.courseId" class="vocab-courses-widget__item">
<div class="vocab-courses-widget__main">
<strong class="vocab-courses-widget__course-title">{{ c.title || $t('widgets.vocabCourses.unnamedCourse') }}</strong>
<strong class="vocab-courses-widget__course-title">{{ displayCourseTitle(c) }}</strong>
<span v-if="c.currentLesson" class="vocab-courses-widget__lesson">
{{ $t('widgets.vocabCourses.lessonLine', { number: c.currentLesson.lessonNumber, title: c.currentLesson.title }) }}
</span>
@@ -42,6 +42,8 @@
</template>
<script>
import { localizeVocabCourseTitle } from '@/utils/vocabCourseTitle.js';
export default {
name: 'VocabCoursesWidget',
props: {
@@ -58,6 +60,9 @@ export default {
}
},
methods: {
displayCourseTitle(course) {
return localizeVocabCourseTitle(course?.title, this.$i18n?.locale) || this.$t('widgets.vocabCourses.unnamedCourse');
},
goToLesson(courseId, lessonId) {
this.$router.push({
name: 'VocabLesson',

View File

@@ -202,6 +202,18 @@
"stockHint": "Mubo nga tan-aw sa mga baligya ug stock sa tanang rehiyon.",
"open": "Ablihi"
},
"productions": {
"title": "Mga produksyon"
},
"stock": {
"title": "Bodega"
},
"branches": {
"title": "Mga branch",
"level": {
"city": "Siyudad"
}
},
"routine": {
"branch": {
"kicker": "Rutina",
@@ -1262,6 +1274,84 @@
"partial_success": "Partial success",
"major_success": "Major success"
}
},
"titles": {
"male": {
"noncivil": "Ubos nga lumulupyo",
"civil": "Luwas nga lumulupyo",
"sir": "Ginoo",
"townlord": "Pangulo sa lungsod",
"by": "sa",
"landlord": "Tag-iya sa yuta",
"knight": "Kabalyero",
"baron": "Baron",
"count": "Konde",
"palsgrave": "Palatine nga Konde",
"margrave": "Markgraf",
"landgrave": "Landgraf",
"ruler": "Prinsipe",
"elector": "Elektor",
"imperial-prince": "Prinsipe sa imperyo",
"duke": "Duke",
"grand-duke": "Dakong Duke",
"prince-regent": "Prinsipe-Regente",
"king": "Hari"
},
"female": {
"noncivil": "Ubos nga lumulupyo",
"civil": "Luwas nga lumulupyo",
"sir": "Ginang",
"townlord": "Pangulo sa lungsod",
"by": "sa",
"landlord": "Tag-iya sa yuta",
"knight": "Kabalyera",
"baron": "Baronesa",
"count": "Kondesa",
"palsgrave": "Palatine nga Kondesa",
"margrave": "Margrabin",
"landgrave": "Landgrabin",
"ruler": "Prinsesa",
"elector": "Elektora",
"imperial-prince": "Prinsesa sa imperyo",
"duke": "Dukesa",
"grand-duke": "Dakong Dukesa",
"prince-regent": "Prinsesa-Regente",
"king": "Rayna"
}
},
"product": {
"wheat": "Trigo",
"grain": "Grano",
"carrot": "Karot",
"fish": "Isda",
"meat": "Karne",
"leather": "Panit",
"wood": "Kahoy",
"stone": "Bato",
"milk": "Gatas",
"cheese": "Keso",
"bread": "Pan",
"beer": "Serbesa",
"iron": "Puthaw",
"copper": "Tumbaga",
"spices": "Panakot",
"salt": "Asin",
"sugar": "Asukal",
"vinegar": "Suka",
"cotton": "Gapas",
"wine": "Bino",
"gold": "Bulawan",
"diamond": "Diamante",
"furniture": "Muwebles",
"clothing": "Sinina",
"jewelry": "Alahas",
"painting": "Pintura",
"book": "Libro",
"weapon": "Hinagiban",
"armor": "Armadura",
"shield": "Taming",
"horse": "Kabayo",
"ox": "Baka"
}
}
}

View File

@@ -510,6 +510,10 @@
"lessonReviewHeadlineScheduled": "Gitakda kini nga leksiyon para sa sunod nga review wave.",
"lessonReviewHintDone": "Nahuman na ang 1/3/7 ka adlaw nga balik-balik. Mahimo na nimo kining praktison sa mas luag nga paagi.",
"lessonReviewHintNextDue": "Sunod nga petsa: {due}.",
"dailyEnoughTitle": "Para karon, igo na gyud ni.",
"dailyEnoughBody": "Naabot na nimo ang girekomenda nga target para niining leksiyona. Kung gusto gyud ka, pwede ra ka mopadayon:",
"dailyEnoughStop": "Undang sa karon",
"dailyEnoughContinue": "Padayon gihapon ug praktis",
"reviewTimeNow": "karon",
"reviewTimeTomorrow": "ugma",
"reviewTimeInDays": "sulod sa {count} ka adlaw",

View File

@@ -817,6 +817,10 @@
"lessonReviewHeadlineScheduled": "Diese Lektion ist für die nächste Review-Welle vorgemerkt.",
"lessonReviewHintDone": "Die 1/3/7-Tage-Wiederholung ist abgeschlossen. Du kannst die Lektion jetzt flexibel weitertrainieren.",
"lessonReviewHintNextDue": "Nächste Fälligkeit: {due}.",
"dailyEnoughTitle": "Für heute ist das eigentlich genug.",
"dailyEnoughBody": "Du hast die empfohlenen Ziele für diese Lektion erreicht. Wenn du unbedingt möchtest, kannst du natürlich weiter üben:",
"dailyEnoughStop": "Für heute beenden",
"dailyEnoughContinue": "Trotzdem weiter üben",
"reviewTimeNow": "jetzt",
"reviewTimeTomorrow": "morgen",
"reviewTimeInDays": "in {count} Tagen",

View File

@@ -817,6 +817,10 @@
"lessonReviewHeadlineScheduled": "This lesson is scheduled for the next review wave.",
"lessonReviewHintDone": "The 1/3/7-day review cycle is complete. You can now continue practicing this lesson freely.",
"lessonReviewHintNextDue": "Next due date: {due}.",
"dailyEnoughTitle": "That is basically enough for today.",
"dailyEnoughBody": "You have reached the recommended targets for this lesson. If you really want to, you can keep going:",
"dailyEnoughStop": "Stop for today",
"dailyEnoughContinue": "Keep practicing anyway",
"reviewTimeNow": "now",
"reviewTimeTomorrow": "tomorrow",
"reviewTimeInDays": "in {count} days",

View File

@@ -806,6 +806,10 @@
"lessonReviewHeadlineScheduled": "Esta lección está prevista para la siguiente ola de repaso.",
"lessonReviewHintDone": "El ciclo de repaso de 1/3/7 días está completado. Ahora puedes seguir practicando esta lección libremente.",
"lessonReviewHintNextDue": "Próximo vencimiento: {due}.",
"dailyEnoughTitle": "En principio, por hoy ya es suficiente.",
"dailyEnoughBody": "Has alcanzado los objetivos recomendados para esta lección. Si de verdad quieres, puedes seguir:",
"dailyEnoughStop": "Parar por hoy",
"dailyEnoughContinue": "Seguir practicando de todos modos",
"reviewTimeNow": "ahora",
"reviewTimeTomorrow": "mañana",
"reviewTimeInDays": "en {count} días"

View File

@@ -806,6 +806,10 @@
"lessonReviewHeadlineScheduled": "Cette leçon est réservée à la prochaine vague de critiques.",
"lessonReviewHintDone": "La répétition 1/3/7 jour est terminée. Vous pouvez désormais continuer à entraîner la leçon de manière flexible.",
"lessonReviewHintNextDue": "Prochaine date d'échéance : {due}.",
"dailyEnoughTitle": "Pour aujourd'hui, c'est en principe suffisant.",
"dailyEnoughBody": "Vous avez atteint les objectifs recommandés pour cette leçon. Si vous le souhaitez vraiment, vous pouvez continuer :",
"dailyEnoughStop": "Arrêter pour aujourd'hui",
"dailyEnoughContinue": "Continuer quand même",
"reviewTimeNow": "maintenant",
"reviewTimeTomorrow": "matin",
"reviewTimeInDays": "dans {count} jours"

View File

@@ -0,0 +1,17 @@
const COURSE_TITLE_TRANSLATIONS = {
'Deutsch für Bisaya-Lernende - Alltag & Stabilisierung': {
ceb: 'Aleman para sa mga Bisaya nga Tigkat-on - Adlaw-adlaw ug Pagpalig-on'
},
'Bisaya für Familien - Alltag & Stabilisierung': {
ceb: 'Bisaya para sa mga Pamilya - Adlaw-adlaw ug Pagpalig-on'
}
};
export function localizeVocabCourseTitle(rawTitle, locale) {
const title = String(rawTitle || '').trim();
if (!title) return '';
const normalizedLocale = String(locale || 'de').toLowerCase();
const entry = COURSE_TITLE_TRANSLATIONS[title];
if (!entry) return title;
return entry[normalizedLocale] || title;
}

View File

@@ -40,7 +40,7 @@
</div>
<p class="language-assistant-settings__preset-hint">
Der Ollama-Preset setzt <code>http://127.0.0.1:11434/v1</code> und
<code>qwen2.5:7b-instruct</code>. Kein API-Key erforderlich.
<code>qwen2.5:3b-instruct</code>. Kein API-Key erforderlich.
</p>
</div>
@@ -137,7 +137,7 @@ export default {
applyOllamaPreset() {
this.form.enabled = true;
this.form.baseUrl = 'http://127.0.0.1:11434/v1';
this.form.model = 'qwen2.5:7b-instruct';
this.form.model = 'qwen2.5:3b-instruct';
this.form.apiKey = '';
this.form.clearKey = false;
},

View File

@@ -53,7 +53,7 @@
<div class="course-content">
<div class="course-info">
<div class="course-title-row">
<span class="course-title">{{ course.title }}</span>
<span class="course-title">{{ displayCourseTitle(course) }}</span>
<span v-if="course.isOwner" class="badge owner">{{ $t('socialnetwork.vocab.courses.owner') }}</span>
<span v-else-if="course.enrolledAt" class="badge enrolled">{{ $t('socialnetwork.vocab.courses.enrolled') }}</span>
</div>
@@ -142,6 +142,7 @@
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
import { showApiError, showError } from '@/utils/feedback.js';
import { localizeVocabCourseTitle } from '@/utils/vocabCourseTitle.js';
export default {
name: 'VocabCourseListView',
@@ -172,6 +173,9 @@ export default {
...mapGetters(['user', 'language']),
},
methods: {
displayCourseTitle(course) {
return localizeVocabCourseTitle(course?.title, this.$i18n?.locale || this.language) || this.$t('widgets.vocabCourses.unnamedCourse');
},
async loadLanguages() {
try {
// Verwende /languages/all für die Kursliste, um alle Sprachen anzuzeigen

View File

@@ -5,7 +5,7 @@
<section class="course-hero surface-card">
<div>
<span class="course-kicker">{{ $t('socialnetwork.vocab.courses.courseKicker') }}</span>
<h2>{{ course.title }}</h2>
<h2>{{ displayCourseTitle(course) }}</h2>
<p v-if="course.description">{{ course.description }}</p>
</div>
</section>
@@ -323,6 +323,7 @@ import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
import { confirmAction, showApiError, showInfo, showSuccess } from '@/utils/feedback.js';
import VocabPracticeDialog from '@/dialogues/socialnetwork/VocabPracticeDialog.vue';
import { localizeVocabCourseTitle } from '@/utils/vocabCourseTitle.js';
export default {
name: 'VocabCourseView',
@@ -517,6 +518,9 @@ export default {
}
},
methods: {
displayCourseTitle(course) {
return localizeVocabCourseTitle(course?.title, this.$i18n?.locale) || '';
},
async loadCourse() {
this.loading = true;
try {

View File

@@ -102,6 +102,22 @@
<p>{{ $t('socialnetwork.vocab.courses.intensiveReviewIntro') }}</p>
</div>
<div
v-if="showDailyEnoughBanner"
class="lesson-enough-banner"
>
<strong>{{ $t('socialnetwork.vocab.courses.dailyEnoughTitle') }}</strong>
<p>{{ $t('socialnetwork.vocab.courses.dailyEnoughBody') }}</p>
<div class="lesson-enough-banner__actions">
<button type="button" class="btn-secondary" @click="dismissDailyEnoughBanner">
{{ $t('socialnetwork.vocab.courses.dailyEnoughStop') }}
</button>
<button type="button" class="btn-primary" @click="continueDailyEnoughBanner">
{{ $t('socialnetwork.vocab.courses.dailyEnoughContinue') }}
</button>
</div>
</div>
<section class="lesson-primary-flow surface-card">
<div class="lesson-primary-flow__header">
<span class="lesson-primary-flow__eyebrow">{{ $t('socialnetwork.vocab.courses.learningPathLabel') }}</span>
@@ -1092,6 +1108,7 @@ export default {
chapterExamFailedDetails: [],
/** Index in scrambledChapterExamExercises bei Ein-Frage-Ansicht */
exerciseSequentialIndex: 0,
dailyEnoughBannerDismissed: false,
/** Aus vorherigen Lektionen (MC-Optionen nach Fragentyp Ziel-/Muttersprache) */
distractorPool: { target: [], native: [] },
courseLanguageName: '',
@@ -1438,12 +1455,21 @@ export default {
if (!reference) return;
const key = this.normalizeLessonVocabTerm(reference);
if (!vocabByReference.has(key)) {
vocabByReference.set(key, { learning, reference });
const variants = new Set();
if (learning) variants.add(learning);
vocabByReference.set(key, { learning, reference, learningVariants: variants });
return;
}
const existing = vocabByReference.get(key);
if (!existing.learning && learning) {
existing.learning = learning;
if (learning) {
if (!existing.learning) {
existing.learning = learning;
}
if (existing.learningVariants instanceof Set) {
existing.learningVariants.add(learning);
} else {
existing.learningVariants = new Set([existing.learning, learning].filter(Boolean));
}
}
};
@@ -1458,7 +1484,18 @@ export default {
addEntry(item);
});
return Array.from(vocabByReference.values());
return Array.from(vocabByReference.values()).map((entry) => {
const variants = entry.learningVariants instanceof Set
? Array.from(entry.learningVariants)
: (Array.isArray(entry.learningVariants) ? entry.learningVariants : []);
const uniqueVariants = [...new Set([entry.learning, ...variants].filter(Boolean))];
return {
learning: entry.learning,
reference: entry.reference,
// Für den Trainer: alternative Übersetzungen als gleichwertig akzeptieren.
learningVariants: uniqueVariants
};
});
},
vocabOverviewTotalPages() {
const n = this.lessonVocab.length;
@@ -1673,6 +1710,23 @@ export default {
due: this.formatLessonReviewDue(progress.reviewNextDueAt)
});
},
showDailyEnoughBanner() {
if (this.dailyEnoughBannerDismissed) return false;
const progress = this.lessonProgress;
// Wenn eine Review-Welle fällig ist, ist "für heute genug" nicht korrekt.
if (progress?.completed && progress?.reviewDue) return false;
if (progress?.completed) return true;
// Falls der Fortschritt noch nicht gespeichert ist: Übungen sind vollständig + bestanden.
if (this.hasExercises && this.effectiveExercises.length > 0) {
const allAnswered = this.exerciseAnsweredCount >= this.effectiveExercises.length;
if (allAnswered && this.exerciseProgressPercent >= this.exerciseTargetScore) {
return true;
}
}
return false;
},
assistantAvailable() {
if (!this.assistantSettings) {
return false;
@@ -1748,6 +1802,22 @@ export default {
}
},
methods: {
dismissDailyEnoughBanner() {
this.dailyEnoughBannerDismissed = true;
},
continueDailyEnoughBanner() {
this.dailyEnoughBannerDismissed = true;
if (!this.vocabTrainerActive && this.trainableLessonVocab.length > 0) {
// Startet Trainer nur, wenn die Vorbereitung abgeschlossen ist.
if (this.canStartVocabTrainerPrep) {
this.startVocabTrainer();
} else {
this.vocabTrainerActive = true;
this.vocabTrainerPool = [...this.trainableLessonVocab];
this.$nextTick(() => this.nextVocabQuestion());
}
}
},
vocabOverviewGoPrev() {
if (this.vocabOverviewPagerMeta.page > 1) {
this.vocabOverviewPage -= 1;
@@ -2419,6 +2489,22 @@ export default {
// Nur extrahieren, wenn Muttersprache-Hinweise (Klammern) vorhanden sind
if (nativeWords.length > 0) {
// Wenn möglich: rekonstruiere die vollständige Phrase um die Lücke herum.
// Beispiel: "{gap} ka mubalik? (Bitte wiederholen)" + "Palihug" => "Palihug ka mubalik?"
const stripHints = (s) => String(s || '')
.replace(/\([^)]*\)/g, '')
.replace(/\[[^\]]*\]/g, '')
.replace(/[^]*/g, '');
const stopChars = [',', '.', '|', ';', ':', '\n'];
const lastStopIndex = (s) => Math.max(...stopChars.map((c) => s.lastIndexOf(c)));
const firstStopIndex = (s) => {
const indices = stopChars
.map((c) => s.indexOf(c))
.filter((idx) => idx >= 0);
return indices.length ? Math.min(...indices) : -1;
};
const parts = String(text).split('{gap}');
answers.forEach((answer, index) => {
if (answer && answer.trim()) {
const nativeWord = nativeWords[index];
@@ -2429,13 +2515,38 @@ export default {
const nativeLooksSentence = /[?.!]/.test(nativeText) || nativeWordCount >= 4;
const answerLooksShortToken = answerWordCount <= 2;
const likelyFragmentMismatch = nativeLooksSentence && answerLooksShortToken;
if (nativeText && answerText && nativeText !== answerText && !likelyFragmentMismatch) {
// Die answer ist normalerweise Bisaya, nativeWord ist Muttersprache
// Nur hinzufügen, wenn sie unterschiedlich sind (verhindert "ko" -> "ko")
vocabMap.set(`${nativeText}-${answerText}`, { learning: nativeText, reference: answerText });
debugLog(`[importantVocab] Gap Fill extrahiert - Muttersprache:`, nativeText, `Bisaya:`, answerText);
// Prefer full phrase reconstruction when we have matching "{gap}" placeholders.
let reconstructed = '';
if (parts.length === answers.length + 1) {
const leftRaw = stripHints(parts[index] || '');
const rightRaw = stripHints(parts[index + 1] || '');
const leftCut = lastStopIndex(leftRaw);
const leftTail = leftCut >= 0 ? leftRaw.slice(leftCut + 1) : leftRaw;
const rightCut = firstStopIndex(rightRaw);
const rightHead = rightCut >= 0 ? rightRaw.slice(0, rightCut) : rightRaw;
reconstructed = `${leftTail}${answerText}${rightHead}`
.replace(/\s+/g, ' ')
.trim();
}
const reconstructedIsUseful =
reconstructed &&
reconstructed !== answerText &&
(reconstructed.includes(' ') || /[?!]/.test(reconstructed));
if (nativeText && (reconstructedIsUseful || answerText) && nativeText !== answerText && !likelyFragmentMismatch) {
const targetText = reconstructedIsUseful ? reconstructed : answerText;
// Sicherheitsfilter: wir wollen primär echte Phrasen, nicht einzelne Tokens als "Vokabelkarte".
const targetWordCount = targetText.split(/\s+/).filter(Boolean).length;
if (targetWordCount < 2 && !/[?!]/.test(targetText)) {
debugLog(`[importantVocab] Gap Fill übersprungen - Ziel wirkt zu kurz:`, nativeText, targetText);
return;
}
// Muttersprache (learning) -> Zielsprache (reference)
vocabMap.set(`${nativeText}-${targetText}`, { learning: nativeText, reference: targetText });
debugLog(`[importantVocab] Gap Fill extrahiert - Muttersprache:`, nativeText, `Bisaya:`, targetText);
} else {
debugLog(`[importantVocab] Gap Fill übersprungen - Satz/Fragment-Mismatch oder gleich:`, nativeText, answerText);
debugLog(`[importantVocab] Gap Fill übersprungen - Satz/Fragment-Mismatch oder gleich:`, nativeText, answerText, reconstructed);
}
}
});
@@ -2478,6 +2589,7 @@ export default {
this.exerciseRetryPending = false;
this.exerciseRetryPendingSinceAttempts = 0;
this.exerciseSequentialIndex = 0;
this.dailyEnoughBannerDismissed = false;
this.vocabOverviewPage = 1;
this.exercisePreparationCompleted = false;
this.lessonPrepStage = 0;
@@ -3403,10 +3515,12 @@ export default {
const didacticMode = String(this.lessonPedagogy?.didacticMode || '').toLowerCase();
const isReviewLesson = ['review', 'vocab_review'].includes(lessonType)
|| ['review', 'vocab_review', 'intensive_review'].includes(didacticMode);
// Reviews sollen nicht nur aus Multiple Choice bestehen:
// früherer Wechsel zu Typing, damit aktiver Abruf im Vordergrund steht.
const switchAfterAttempts = isReviewLesson
? Math.max(4, Math.ceil(this.trainerExerciseUnlockAttempts * 0.25))
? Math.max(2, Math.ceil(this.trainerExerciseUnlockAttempts * 0.15))
: this.trainerExerciseUnlockAttempts;
const requiredSuccessRate = isReviewLesson ? 70 : 80;
const requiredSuccessRate = isReviewLesson ? 60 : 80;
if (this.vocabTrainerMode === 'multiple_choice' && this.vocabTrainerTotalAttempts >= switchAfterAttempts) {
const successRate = (this.vocabTrainerCorrect / this.vocabTrainerTotalAttempts) * 100;
@@ -3617,20 +3731,17 @@ export default {
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
const allTrainerVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
const prompt = this.vocabTrainerDirection === 'L2R' ? vocab.learning : vocab.reference;
const acceptableAnswers = this.getEquivalentVocabAnswers(
prompt,
this.vocabTrainerDirection,
allTrainerVocabs
);
// Akzeptiere mehrere Übersetzungen für denselben Prompt (z. B. "Bitte wiederholen" UND "Kannst du das wiederholen?").
const acceptableAnswers = this.vocabTrainerDirection === 'L2R'
? [vocab.reference]
: (Array.isArray(vocab.learningVariants) && vocab.learningVariants.length > 0
? vocab.learningVariants
: [vocab.learning]);
this.currentVocabQuestion = {
vocab: vocab,
prompt,
answers: acceptableAnswers.length > 0
? acceptableAnswers
: [this.vocabTrainerDirection === 'L2R' ? vocab.reference : vocab.learning],
answer: acceptableAnswers.length > 0
? acceptableAnswers.join(' / ')
: (this.vocabTrainerDirection === 'L2R' ? vocab.reference : vocab.learning),
answers: acceptableAnswers.filter(Boolean),
answer: acceptableAnswers.filter(Boolean).join(' / ') || (this.vocabTrainerDirection === 'L2R' ? vocab.reference : vocab.learning),
key: this.getVocabKey(vocab),
source: questionSource
};
@@ -4149,6 +4260,31 @@ export default {
margin-top: 6px;
}
.lesson-enough-banner {
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(34, 96, 164, 0.18);
background: rgba(235, 244, 255, 0.72);
color: #21598f;
}
.lesson-enough-banner strong,
.lesson-enough-banner p {
margin: 0;
}
.lesson-enough-banner p {
margin-top: 6px;
}
.lesson-enough-banner__actions {
margin-top: 12px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.lesson-overview-more {
border-top: 1px solid rgba(160, 120, 40, 0.18);
padding-top: 14px;