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

@@ -104,7 +104,8 @@ const LESSON_DIDACTICS = {
],
grammarFocus: [
{ title: 'Bitte-Formeln mit palihug', text: '"Palihug" macht Bitten höflich und taucht in vielen Überlebenssätzen auf.', example: 'Palihug ka mubalik? / Tabangi ko, palihug.' },
{ title: 'Kurze Verständnisfragen', text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.', example: 'Unsay pasabot ani? Asa ang CR?' }
{ title: 'Kurze Verständnisfragen', text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.', example: 'Unsay pasabot ani? Asa ang CR?' },
{ title: 'ko und ka: ich / du im Satz', text: 'ko steht in diesen kurzen Sätzen für „ich/mich“, ka für „du/dich“. Anders als im Deutschen stehen die Formen oft nach dem wichtigen Wort oder Verb.', example: 'Wala ko kasabot. / Palihug ka mubalik? / Tabangi ko, palihug.' }
],
speakingPrompts: [
{ title: 'Wenn du etwas nicht verstehst', prompt: 'Sage, dass du etwas nicht verstehst, und bitte um Wiederholung.', cue: 'Wala ko kasabot. Palihug ka mubalik?' },
@@ -245,6 +246,9 @@ const LESSON_DIDACTICS = {
{ target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.', alternatives: ['Ich ruhe mich später aus.'] },
{ target: 'Tawagi ko kung mahuman ka.', gloss: 'Ruf mich an, wenn du fertig bist.' }
],
grammarFocus: [
{ title: 'imong und koy im Alltag', text: 'imong bedeutet „dein/deine“ vor einem Nomen. koy ist die zusammengezogene Form in „Naa koy …“ und drückt aus, dass du etwas hast oder vorhast.', example: 'Unsa imong buhat karon? / Naa koy lakaw karong hapon.' }
],
speakingPrompts: [
{ title: 'Tagesablauf abstimmen', prompt: 'Frage nach dem Plan und sage, was du heute erledigst.', cue: 'Unsa imong buhat karon? Naglimpyo ko sa balay.' }
],

View File

@@ -51,6 +51,13 @@ export const BISAYA_DIDACTICS_FRAGMENTS = {
cue: 'Unsa imong buhat karon? Naglimpyo ko sa balay.'
}
],
grammarFocus: [
{
title: 'imong und koy im Alltag',
text: 'imong bedeutet „dein/deine“ vor einem Nomen. koy ist die zusammengezogene Form in „Naa koy …“ und drückt aus, dass du etwas hast oder vorhast.',
example: 'Unsa imong buhat karon? / Naa koy lakaw karong hapon.'
}
],
practicalTasks: [
{
title: 'Alltagscheck',

View File

@@ -3582,6 +3582,18 @@ const BISAYA_EXERCISES = {
},
explanation: '"Wala ko kasabot" = "Ich verstehe nicht", "Palihug ka mubalik?" = "Bitte wiederholen".'
},
{
exerciseTypeId: 1,
title: 'ko oder ka? erste Personenformen',
instruction: 'Ergänze ko oder ka. Lies jeden Satz anschließend laut.',
questionData: {
type: 'gap_fill',
text: 'Wala {gap} kasabot. (Ich verstehe nicht.) | Palihug {gap} mubalik? (Kannst du bitte wiederholen?) | Tabangi {gap}, palihug. (Hilf mir bitte.)',
gaps: 3
},
answerData: { type: 'gap_fill', answers: ['ko', 'ka', 'ko'] },
explanation: 'ko steht hier für „ich/mich“: Wala ko kasabot, Tabangi ko. ka richtet sich an die andere Person: Palihug ka mubalik?'
},
{
exerciseTypeId: 4,
title: '„Wo ist die Toilette?" übersetzen',
@@ -3708,6 +3720,18 @@ const BISAYA_EXERCISES = {
// Lektion 11: Alltagsgespräche - Teil 1 (~2030 Min Übungsmaterial)
'Alltagsgespräche - Teil 1': [
{
exerciseTypeId: 1,
title: 'imong oder koy? Besitz und eigener Plan',
instruction: 'Ergänze die passende kurze Form.',
questionData: {
type: 'gap_fill',
text: 'Unsa {gap} buhat karon? (Was machst du heute?) | Naa {gap} lakaw karong hapon. (Ich habe heute Nachmittag etwas zu erledigen.)',
gaps: 2
},
answerData: { type: 'gap_fill', answers: ['imong', 'koy'] },
explanation: 'imong heißt „dein/deine“ und steht vor buhat. koy ist die zusammengezogene Form in Naa koy …: „Ich habe / bei mir ist …“. '
},
{
exerciseTypeId: 2, // multiple_choice
title: 'Wie sagt man "Wie war dein Tag?"?',

View File

@@ -174,6 +174,11 @@ const LESSON_DIDACTICS = {
title: 'Kurze Verständnisfragen',
text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.',
example: 'Unsay pasabot ani? Asa ang CR?'
},
{
title: 'ko und ka: ich / du im Satz',
text: 'ko steht in diesen kurzen Sätzen für „ich/mich“, ka für „du/dich“. Anders als im Deutschen stehen die Formen oft nach dem wichtigen Wort oder Verb.',
example: 'Wala ko kasabot. / Palihug ka mubalik? / Tabangi ko, palihug.'
}
],
speakingPrompts: [
@@ -401,6 +406,13 @@ const LESSON_DIDACTICS = {
{ target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.', alternatives: ['Ich ruhe mich später aus.'] },
{ target: 'Tawagi ko kung mahuman ka.', gloss: 'Ruf mich an, wenn du fertig bist.' }
],
grammarFocus: [
{
title: 'imong und koy im Alltag',
text: 'imong bedeutet „dein/deine“ vor einem Nomen. koy ist die zusammengezogene Form in „Naa koy …“ und drückt aus, dass du etwas hast oder vorhast.',
example: 'Unsa imong buhat karon? / Naa koy lakaw karong hapon.'
}
],
speakingPrompts: [
{
title: 'Tagesablauf abstimmen',

View File

@@ -136,7 +136,8 @@ export const LESSON_DIDACTICS = {
],
grammarFocus: [
{ title: 'Bitte-Formeln mit palihug', text: '"Palihug" macht Bitten höflich und taucht in vielen Überlebenssätzen auf.', example: 'Palihug ka mubalik? / Tabangi ko, palihug.' },
{ title: 'Kurze Verständnisfragen', text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.', example: 'Unsay pasabot ani? Asa ang CR?' }
{ title: 'Kurze Verständnisfragen', text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.', example: 'Unsay pasabot ani? Asa ang CR?' },
{ title: 'ko und ka: ich / du im Satz', text: 'ko steht in diesen kurzen Sätzen für „ich/mich“, ka für „du/dich“. Anders als im Deutschen stehen die Formen oft nach dem wichtigen Wort oder Verb.', example: 'Wala ko kasabot. / Palihug ka mubalik? / Tabangi ko, palihug.' }
],
speakingPrompts: [
{ title: 'Wenn du etwas nicht verstehst', prompt: 'Sage, dass du etwas nicht verstehst, und bitte um Wiederholung.', cue: 'Wala ko kasabot. Palihug ka mubalik?' },

View File

@@ -2918,6 +2918,33 @@ export default class VocabService {
};
}
_buildSyntheticLexemeGapExercisePlain(lessonId, row, exerciseNumber) {
const learning = String(row.learning || '').trim();
const reference = String(row.reference || '').trim();
if (!learning || !reference) {
return null;
}
// A short contextual tail makes multi-word entries a real gap exercise,
// while single words still have a useful translation cue.
const parts = reference.split(/\s+/).filter(Boolean);
const text = parts.length > 1
? `{gap} ${parts.slice(1).join(' ')} (${learning})`
: `{gap} (${learning})`;
return {
id: `syn-gap-${lessonId}-${row.id}-l2r`,
lessonId,
exerciseTypeId: 1,
exerciseType: { id: 1, name: 'gap_fill' },
exerciseNumber,
title: `Fehlendes Wort: ${learning}`,
instruction: 'Ergänze das fehlende Wort.',
questionData: { type: 'gap_fill', text },
answerData: { type: 'gap_fill', answers: [parts[0]] },
explanation: null,
createdByUserId: 0
};
}
async _fetchChapterLexemeRowsForMc(chapterId) {
const id = Number.parseInt(chapterId, 10);
if (!Number.isFinite(id)) {
@@ -2951,26 +2978,9 @@ export default class VocabService {
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review' || plainLesson.lessonType === 'weekly_review') {
return list;
}
let rows = [];
// If this lesson belongs to a week, prefer vocab from previous lessons of the same week
if (plainLesson.weekNumber) {
try {
const weekExercises = await this._getWeekVocabExercises(plainLesson.courseId, plainLesson.weekNumber, plainLesson.lessonNumber);
const extracted = this._extractTrainerVocabsFromExercises(weekExercises || [], { allowGapFill: false });
if (extracted && extracted.length) {
// Map extracted pairs to row-like objects with numeric ids
rows = extracted.map((item, idx) => ({ id: 2000000 + idx, learning: item.learning, reference: item.reference }));
}
} catch (err) {
// ignore and fallback to chapter lexemes
rows = [];
}
}
if (!rows.length) {
rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId);
}
// Synthetic IDs are checked against the chapter lexeme table. Do not use
// extracted, temporary weekly rows here: they have no stable ID to verify.
const rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId);
if (!rows.length) {
plainLesson.chapterLexemeExamCount = 0;
@@ -3044,6 +3054,19 @@ export default class VocabService {
}
}
// Add active-recall questions as well as recognition questions. A smaller
// sample keeps a chapter test manageable while ensuring every test contains
// several genuine "which word is missing?" tasks.
const gapTarget = Math.min(8, Math.max(3, Math.ceil(examSelected.length * 0.5)));
const gapRows = this._seededShuffle(examSelected.slice(), (seed + 7919) >>> 0).slice(0, gapTarget);
for (const row of gapRows) {
maxNum += 1;
const ex = this._buildSyntheticLexemeGapExercisePlain(plainLesson.id, row, maxNum);
if (ex) {
list.push(ex);
}
}
return list;
}
@@ -3121,6 +3144,43 @@ export default class VocabService {
};
}
async _checkSyntheticLexemeGapAnswer(user, lessonId, chapterLexemeId, userAnswer) {
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson || (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic)) {
const err = new Error('Exercise not found');
err.status = lesson ? 403 : 404;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment || !lesson.chapterId) {
const err = new Error('Exercise not found');
err.status = enrollment ? 404 : 403;
throw err;
}
const rows = await this._fetchChapterLexemeRowsForMc(lesson.chapterId);
const row = rows.find((entry) => Number(entry.id) === Number(chapterLexemeId));
const referenceParts = String(row?.reference || '').trim().split(/\s+/).filter(Boolean);
if (!referenceParts.length) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
const correctAnswer = referenceParts[0];
const submitted = Array.isArray(userAnswer) ? userAnswer[0] : userAnswer;
const correct = this._isEquivalentAnswer(submitted, correctAnswer);
return {
correct,
correctAnswer,
alternatives: [],
explanation: null,
progress: { attempts: 1, correctAttempts: correct ? 1 : 0, lastAttemptAt: new Date(), completed: correct, completedAt: correct ? new Date() : null }
};
}
async getLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
@@ -4483,6 +4543,15 @@ export default class VocabService {
userAnswer
);
}
const synGapMatch = /^syn-gap-(\d+)-(\d+)-l2r$/.exec(exIdStr);
if (synGapMatch) {
return this._checkSyntheticLexemeGapAnswer(
user,
Number(synGapMatch[1]),
Number(synGapMatch[2]),
userAnswer
);
}
const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }