feat(bisaya): enhance grammar focus and exercises in Bisaya course content
All checks were successful
Deploy to production / deploy (push) Successful in 4m10s

This commit is contained in:
Torsten Schulz (local)
2026-09-22 09:54:27 +02:00
parent afbfdd8ccc
commit 039059b4ac
7 changed files with 294 additions and 69 deletions

View File

@@ -305,9 +305,10 @@
</div>
</div>
<div v-if="currentVocabQuestion" class="vocab-question">
<div class="vocab-prompt">
<div class="vocab-direction">{{ vocabTrainerDirection === 'L2R' ? $t('socialnetwork.vocab.courses.translateTo') : $t('socialnetwork.vocab.courses.translateFrom') }}</div>
<div class="vocab-prompt">
<div class="vocab-direction">{{ currentVocabQuestion.directionLabel || (vocabTrainerDirection === 'L2R' ? $t('socialnetwork.vocab.courses.translateTo') : $t('socialnetwork.vocab.courses.translateFrom')) }}</div>
<div class="vocab-word">{{ currentVocabQuestion.prompt }}</div>
<p v-if="currentVocabQuestion.grammarHint" class="vocab-grammar-hint">{{ currentVocabQuestion.grammarHint }}</p>
</div>
<div v-if="vocabTrainerAnswered" class="vocab-feedback" :class="{ correct: vocabTrainerLastCorrect, wrong: !vocabTrainerLastCorrect }">
<div v-if="vocabTrainerLastCorrect">{{ $t('socialnetwork.vocab.courses.correct') }}!</div>
@@ -1464,14 +1465,11 @@ export default {
exerciseTargetScore() {
return Number(this.lesson?.targetScorePercent) || 80;
},
/** Kapitel-Prüfung: eine Frage pro Ansicht (Essen & Trinken: deterministisch gemischt). */
/** Kapitel-Prüfung: eine Frage pro Ansicht, immer unabhängig von der Eingabereihenfolge gemischt. */
scrambledChapterExamExercises() {
const raw = this.effectiveExercises;
if (!raw.length) return [];
if ((this.lesson?.title || '').trim() === 'Essen & Trinken') {
return this._deterministicShuffle(raw.slice(), Number(this.lessonId) || 1);
}
return raw;
return this._deterministicShuffle(raw.slice(), Number(this.lessonId) || 1);
},
sequentialPanelActive() {
return (this.scrambledChapterExamExercises?.length || 0) > 1;
@@ -2908,46 +2906,6 @@ export default {
}
await this.$nextTick();
let exercises = this.effectiveExercises;
// Wenn das Backend eine chapterLexemeTraining-Liste liefert, wandle sie
// in temporäre gap_fill-Übungen um und mische sie in die grammarExercises
try {
if (this.lesson && Array.isArray(this.lesson.chapterLexemeTraining) && this.lesson.chapterLexemeTraining.length) {
const temps = this.lesson.chapterLexemeTraining.map((item, idx) => {
const id = `cht-${this.lesson.id}-${String(item.id)}`;
// Wenn reference mehrere Wörter enthält, ersetze das erste Wort durch die Lücke
const ref = String(item.reference || '').trim();
let qText = `{gap} (${item.learning})`;
let answers = [ref];
if (ref) {
const parts = ref.split(/\s+/).filter(Boolean);
if (parts.length > 1) {
const tail = parts.slice(1).join(' ');
qText = `{gap} ${tail}`;
answers = [parts[0]];
} else {
// single token reference: show blank plus learning hint
qText = `{gap} (${item.learning})`;
answers = [parts[0]];
}
}
return {
id,
lessonId: this.lesson.id,
exerciseTypeId: 1,
title: `Kapitel-Vokabel: ${item.learning}`,
instruction: this.$t('socialnetwork.vocab.courses.fillTheBlank') || 'Lückentext',
questionData: { type: 'gap_fill', text: qText },
answerData: { type: 'gap_fill', answers }
};
});
this.lesson.grammarExercises = Array.isArray(this.lesson.grammarExercises)
? [...this.lesson.grammarExercises, ...temps]
: temps;
exercises = this.effectiveExercises;
}
} catch (err) {
console.warn('[VocabLessonView] Fehler beim Einfügen von chapterLexemeTraining:', err);
}
if (!exercises || exercises.length === 0) {
debugLog('[VocabLessonView] Lade Übungen separat...');
await this.loadGrammarExercises();
@@ -4187,6 +4145,129 @@ export default {
}
return arr;
},
isGermanCourse() {
return /^(deutsch|german)$/i.test(String(this.courseLanguageName || '').trim());
},
germanFormSource(vocab) {
if (!this.isGermanCourse()) return '';
return [vocab?.learning, vocab?.reference]
.map((value) => String(value || '').trim())
.find((value) => /^(?:der|die|das)\s+[^\s]+$/i.test(value) || /^[a-zäöüß]+en$/i.test(value)) || '';
},
germanPresentForms(infinitive) {
const verb = String(infinitive || '').trim().toLowerCase();
const irregular = {
gehen: ['gehe', 'gehst', 'geht', 'gehen', 'geht', 'gehen'],
sein: ['bin', 'bist', 'ist', 'sind', 'seid', 'sind'],
haben: ['habe', 'hast', 'hat', 'haben', 'habt', 'haben'],
werden: ['werde', 'wirst', 'wird', 'werden', 'werdet', 'werden'],
sprechen: ['spreche', 'sprichst', 'spricht', 'sprechen', 'sprecht', 'sprechen'],
fahren: ['fahre', 'fährst', 'fährt', 'fahren', 'fahrt', 'fahren'],
lesen: ['lese', 'liest', 'liest', 'lesen', 'lest', 'lesen'],
sehen: ['sehe', 'siehst', 'sieht', 'sehen', 'seht', 'sehen']
};
if (irregular[verb]) return irregular[verb];
// Regular verbs only: this intentionally avoids teaching incorrect
// stem changes for verbs whose form is not known here.
if (!/^[a-zäöüß]+en$/i.test(verb) || /(?:eln|ern)$/i.test(verb)) return null;
const stem = verb.slice(0, -2);
if (!stem) return null;
const needsExtraE = /(?:d|t|chn|ffn|gn|tm)$/i.test(stem);
const du = /(?:s|ß|x|z)$/i.test(stem) ? `${stem}t` : `${stem}${needsExtraE ? 'est' : 'st'}`;
const er = `${stem}${needsExtraE ? 'et' : 't'}`;
return [`${stem}e`, du, er, `${stem}en`, `${stem}${needsExtraE ? 'et' : 't'}`, `${stem}en`];
},
buildGrammarPracticeQuestion(vocab, source) {
const german = this.germanFormSource(vocab);
if (!german || Math.random() >= 0.35) return null;
const articleMatch = german.match(/^(der|die|das)\s+([^\s]+)$/i);
if (articleMatch) {
const article = articleMatch[1].toLowerCase();
const noun = articleMatch[2];
return {
vocab,
prompt: `_____ ${noun}`,
answers: [article],
answer: article,
key: this.getVocabKey(vocab),
source,
kind: 'article',
directionLabel: 'Welcher Artikel passt?',
choiceOptions: ['der', 'die', 'das']
};
}
const forms = this.germanPresentForms(german);
if (!forms) return null;
const pronouns = ['ich', 'du', 'er/sie/es', 'wir', 'ihr', 'sie/Sie'];
const index = Math.floor(Math.random() * pronouns.length);
const answer = forms[index];
return {
vocab,
prompt: `${pronouns[index]} _____ (${german})`,
answers: [answer],
answer,
key: this.getVocabKey(vocab),
source,
kind: 'conjugation',
directionLabel: 'Setze die richtige Verbform ein.',
choiceOptions: [...new Set(forms)].sort(() => Math.random() - 0.5)
};
},
isBisayaCourse() {
return /(?:bisaya|cebuano)/i.test(String(this.courseLanguageName || '').trim());
},
bisayaCliticRule(token) {
const rules = {
ko: 'ko: „ich/mir“ als kurze Personenform, z. B. „Maayo ko.“',
ka: 'ka: „du/dir“ als kurze Personenform, z. B. „Kumusta ka?“',
imong: 'imong: „dein/deine“ direkt vor einem Nomen, z. B. „imong bag“.',
koy: 'koy: zusammengezogene Form von ko + ay; häufig nach naa/aduna: „Naa koy …“',
nako: 'nako: „von mir/für mich“ in Mustern wie „Kinahanglan nako …“',
nimo: 'nimo: „von dir/für dich“ bzw. „du“ in Mustern wie „Pwede nimo ko tabangan?“',
tika: 'tika: „dich“ als angehängte Objektform, z. B. „Tabangan tika.“'
};
return rules[token] || '';
},
lessonPatternTexts() {
const patterns = Array.isArray(this.lessonDidactics?.corePatterns)
? this.lessonDidactics.corePatterns
: [];
const grammarExamples = Array.isArray(this.lessonDidactics?.grammarFocus)
? this.lessonDidactics.grammarFocus.map((entry) => entry?.example)
: [];
return [...patterns, ...grammarExamples]
.map((entry) => typeof entry === 'object' ? entry?.target : entry)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
},
buildBisayaLessonPatternQuestion(vocab, source) {
if (!this.isBisayaCourse() || Math.random() >= 0.35) return null;
const tokens = ['imong', 'nimo', 'nako', 'tika', 'koy', 'ko', 'ka'];
const candidates = [];
this.lessonPatternTexts().forEach((text) => {
tokens.forEach((token) => {
const matcher = new RegExp(`(^|\\s)${token}(?=\\s|[,.?!]|$)`, 'i');
if (!matcher.test(text)) return;
candidates.push({ text, token });
});
});
if (!candidates.length) return null;
const picked = candidates[Math.floor(Math.random() * candidates.length)];
const matcher = new RegExp(`(^|\\s)${picked.token}(?=\\s|[,.?!]|$)`, 'i');
const prompt = picked.text.replace(matcher, (_match, prefix) => `${prefix}_____`);
return {
vocab,
prompt,
answers: [picked.token],
answer: picked.token,
key: this.getVocabKey(vocab),
source,
kind: 'bisaya_clitic',
directionLabel: 'Ergänze die passende Bisaya-Form.',
grammarHint: this.bisayaCliticRule(picked.token),
choiceOptions: this._shuffleArray(['ko', 'ka', 'imong', 'koy', 'nako', 'nimo', 'tika'])
};
},
nextVocabQuestion() {
debugLog('[VocabLessonView] nextVocabQuestion aufgerufen');
this.clearVocabTrainerContinueTimer();
@@ -4318,6 +4399,25 @@ export default {
this.currentVocabQuestion = null;
return;
}
// Grammar belongs in the trainer itself: German learners regularly
// retrieve articles and verb forms alongside translations.
const grammarQuestion = !dueRepeatVocab
? (this.buildBisayaLessonPatternQuestion(vocab, questionSource)
|| this.buildGrammarPracticeQuestion(vocab, questionSource))
: null;
if (grammarQuestion) {
this.currentVocabQuestion = grammarQuestion;
this.vocabTrainerAnswer = '';
this.vocabTrainerSelectedChoice = null;
this.vocabTrainerAnswered = false;
if (this.vocabTrainerMode === 'multiple_choice') {
this.vocabTrainerChoiceOptions = grammarQuestion.choiceOptions;
}
if (this.vocabTrainerMode === 'typing') {
this.$nextTick(() => this.$refs.vocabInput?.focus?.());
}
return;
}
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
const allTrainerVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
const direction = this.vocabTrainerDirection;
@@ -6082,6 +6182,14 @@ export default {
color: #333;
}
.vocab-grammar-hint {
margin: 10px 0 0;
padding-top: 9px;
border-top: 1px solid #ddd;
color: #4b5563;
font-size: 0.92em;
}
.vocab-answer-area {
margin-bottom: 15px;
}