feat: add A2-B1 course expansion features including listening activities and conversation sessions

- Updated VocabCourseView.vue to display CEFR level for courses.
- Enhanced VocabLessonView.vue with new sections for listening activities and conversation labs, including audio controls and transcript toggling.
- Created migration to add new columns to vocab_course and new tables for conversation sessions and listening activities.
- Implemented models for vocab_conversation_session and vocab_listening_activity.
- Developed scripts to create new A2 and B1 bridge courses with structured lesson plans.
- Documented the A2-B1 expansion plan detailing course structure, learning goals, and assessment criteria.
This commit is contained in:
Torsten Schulz (local)
2026-08-27 15:18:16 +02:00
parent bb719b3c75
commit 3248579529
16 changed files with 680 additions and 12 deletions

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Erstellt den eigenständigen Anschlusskurs „Bisaya A2 Alltag & Gespräch“.
* Verwendung: node backend/scripts/create-bisaya-a2-course.js <ownerHashedId>
*/
import crypto from 'crypto';
import { sequelize } from '../utils/sequelize.js';
import User from '../models/community/user.js';
import VocabCourse from '../models/community/vocab_course.js';
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
const BLOCKS = [
['Wohnen & Reparaturen', 'Wohnung beschreiben, Probleme melden und eine Reparatur organisieren.', 'Naay problema sa balay.', 'Es gibt ein Problem im Haus.'],
['Arbeit & Tagesorganisation', 'Aufgaben, Arbeitszeiten und Prioritäten nachvollziehbar erklären.', 'Humanon nako una kini.', 'Ich mache das zuerst fertig.'],
['Schule & digitale Kommunikation', 'Schule, Nachrichten und einfache Absprachen verbinden.', 'Magpadala ko ug mensahe unya.', 'Ich schicke später eine Nachricht.'],
['Familie & Beziehungen', 'Gefühle, Wünsche und Rücksicht in längeren Antworten ausdrücken.', 'Importante ka para nako.', 'Du bist mir wichtig.'],
['Meinung & Entscheidungen', 'Zustimmen, begründen und höflich eine andere Meinung sagen.', 'Para nako, mas maayo kung…', 'Für mich wäre es besser, wenn …'],
['Erlebnisse erzählen', 'Erlebtes in sinnvoller Reihenfolge erzählen und nachfragen.', 'Pagkahuman, miadto mi sa merkado.', 'Danach gingen wir zum Markt.'],
['Reisen & Unterkunft', 'Fahrt, Unterkunft und Änderungen eines Plans klären.', 'Pwede ba nato usbon ang plano?', 'Können wir den Plan ändern?'],
['Geld, Dokumente & Dienste', 'Zahlung, Dokumente und öffentliche Erledigungen bewältigen.', 'Kinahanglan nako ang resibo.', 'Ich brauche die Quittung.'],
['Gesundheit & unerwartete Probleme', 'Beschwerden genauer beschreiben und Hilfe organisieren.', 'Dugay na ni nagsakit.', 'Das tut schon länger weh.'],
['A2-Transfer: Alltag', 'Mehrere Alltagsthemen ohne feste Reihenfolge verbinden.', 'Una, kinahanglan nato…', 'Zuerst müssen wir …'],
['A2-Transfer: Gespräch', 'Ein längeres Gespräch beginnen, tragen und freundlich beenden.', 'Nasabtan nako imong giingon.', 'Ich verstehe, was du gesagt hast.'],
['A2-Abschluss & Weiterlernen', 'Eigene Stärken und nächste Lernziele benennen.', 'Gusto pa ko mopadayon ug praktis.', 'Ich möchte weiter üben.']
];
const TYPES = ['vocab', 'grammar', 'conversation', 'listening', 'conversation', 'review'];
const SUFFIXES = ['Wortschatz im Kontext', 'Muster & Grammatik', 'Geführtes Gespräch', 'Hörverstehen', 'Rollenspiel', 'Checkpoint'];
async function main() {
const ownerHashedId = String(process.argv[2] || '').trim();
if (!ownerHashedId) throw new Error('Bitte ownerHashedId angeben.');
await sequelize.authenticate();
const owner = await User.findOne({ where: { hashedId: ownerHashedId } });
if (!owner) throw new Error('Nutzer nicht gefunden.');
const [language] = await sequelize.query("SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1", { type: sequelize.QueryTypes.SELECT });
if (!language) throw new Error('Bisaya-Sprache nicht gefunden.');
const baseCourse = await VocabCourse.findOne({
where: { languageId: language.id, courseKind: 'core' }, order: [['id', 'ASC']]
});
const [course] = await VocabCourse.findOrCreate({
where: { title: 'Bisaya A2 Alltag & Gespräch', languageId: language.id },
defaults: {
ownerUserId: owner.id,
description: 'Anschlusskurs für variablere Alltagssprache, Hörverstehen und längere Gespräche.',
nativeLanguageId: null,
difficultyLevel: 2,
cefrLevel: 'A2',
courseKind: 'continuation',
prerequisiteCourseId: baseCourse?.id || null,
isPublic: true,
shareCode: crypto.randomBytes(8).toString('hex')
}
});
let created = 0;
for (const [blockIndex, block] of BLOCKS.entries()) {
const [topic, goal, target, gloss] = block;
for (let offset = 0; offset < 6; offset += 1) {
const lessonNumber = blockIndex * 6 + offset + 1;
const [lesson, made] = await VocabCourseLesson.findOrCreate({
where: { courseId: course.id, lessonNumber },
defaults: {
courseId: course.id, chapterId: null, lessonNumber,
title: `${topic}: ${SUFFIXES[offset]}`,
description: goal,
weekNumber: blockIndex + 1, dayNumber: offset + 1,
lessonType: TYPES[offset], didacticMode: TYPES[offset] === 'review' ? 'checkpoint' : TYPES[offset] === 'listening' ? 'listening' : 'real_life_scenario',
phaseLabel: 'a2', blockNumber: blockIndex + 1, difficultyWeight: 2,
newUnitTarget: offset === 5 ? 0 : 6, reviewWeight: offset === 5 ? 70 : 35,
isIntensiveReview: offset === 5, targetMinutes: offset === 3 ? 25 : 30,
targetScorePercent: 80, requiresReview: offset === 5,
learningGoals: [goal, 'Mindestens vier eigene Sätze zum Thema formulieren.', 'Frühere Muster passend wiederverwenden.'],
corePatterns: [
{ target, gloss },
{ target: 'Pwede nimo ko tabangan?', gloss: 'Kannst du mir helfen?' },
{ target: 'Sa akong hunahuna, maayo ni.', gloss: 'Ich finde, das ist gut.' },
{ target: 'Kung mahimo, buhaton nato unya.', gloss: 'Wenn möglich, machen wir es später.' }
],
grammarFocus: [{ title: 'Muster im Kontext', text: 'Nutze die neuen Formen, um einen vollständigen Gedanken zu verbinden.', example: target }],
speakingPrompts: [{ title: 'Eigene Antwort', prompt: `${goal} Antworte frei in vier bis sechs Sätzen.`, cue: target }],
practicalTasks: [{ title: 'Transfer', text: 'Nutze ein Kernmuster heute in einer echten oder simulierten Nachricht.' }]
}
});
if (made) created += 1;
}
}
console.log(`A2-Kurs ${course.id}: ${created} Lektionen angelegt.`);
}
main().then(() => sequelize.close()).catch((error) => { console.error(error); sequelize.close(); process.exitCode = 1; });

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
/** Erstellt die B1-Brücke als eigenständigen Folgepfad (96 Lektionen). */
import crypto from 'crypto';
import { sequelize } from '../utils/sequelize.js';
import User from '../models/community/user.js';
import VocabCourse from '../models/community/vocab_course.js';
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
const BLOCKS = [
['Arbeit & Bildung', 'Arbeitsaufgaben, Lernen und Ziele vergleichen und begründen.'],
['Wohnen & Gemeinschaft', 'Wohnen, Nachbarschaft und gemeinsame Regeln erläutern.'],
['Gesellschaft & Dienste', 'Öffentliche Themen verständlich erklären und nachfragen.'],
['Medien & digitale Welt', 'Informationen zusammenfassen und vorsichtig bewerten.'],
['Reise & Erfahrungen', 'Erlebnisse zusammenhängend und nachvollziehbar erzählen.'],
['Kultur & Gewohnheiten', 'Unterschiede respektvoll vergleichen und einordnen.'],
['Meinungen & Gründe', 'Eine Position begründen und auf Gegenargumente eingehen.'],
['Konflikt & Verhandlung', 'Interessen erklären, Kompromisse vorschlagen und reagieren.'],
['Hypothesen & Möglichkeiten', 'Bedingungen, Folgen und unsichere Pläne ausdrücken.'],
['Erzählungen vertiefen', 'Hintergrund, Reihenfolge und persönliche Reaktion verbinden.'],
['Längere Gespräche', 'Themenwechsel erkennen und das Gespräch aktiv tragen.'],
['B1-Transfer I', 'Mehrere vertraute Themen spontan verknüpfen.'],
['B1-Transfer II', 'Eine Erfahrung, Meinung und Lösung verbinden.'],
['Fehlertraining', 'Wiederkehrende Fokus-, Aspekt- und Verknüpfungsfehler reduzieren.'],
['Prüfungsszenen', 'Gespräche, Hörtexte und schriftliche Antworten unter Zeitdruck bewältigen.'],
['Weiterlernen', 'Persönliche Lücken erkennen und einen realistischen Übungsplan erstellen.']
];
const KINDS = ['vocab', 'grammar', 'conversation', 'listening', 'conversation', 'review'];
const NAMES = ['Ausdrucksmittel', 'Grammatik im Zusammenhang', 'Gespräch mit Standpunkt', 'Hörverstehen', 'Rollenspiel', 'Transfer-Checkpoint'];
async function main() {
const ownerHashedId = String(process.argv[2] || '').trim();
if (!ownerHashedId) throw new Error('Bitte ownerHashedId angeben.');
await sequelize.authenticate();
const owner = await User.findOne({ where: { hashedId: ownerHashedId } });
const [language] = await sequelize.query("SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1", { type: sequelize.QueryTypes.SELECT });
if (!owner || !language) throw new Error('Nutzer oder Bisaya-Sprache nicht gefunden.');
const a2 = await VocabCourse.findOne({ where: { title: 'Bisaya A2 Alltag & Gespräch', languageId: language.id } });
const [course] = await VocabCourse.findOrCreate({
where: { title: 'Bisaya B1-Brücke Meinung & freie Gespräche', languageId: language.id },
defaults: {
ownerUserId: owner.id, description: 'B1-Teilkompetenzen für vertraute Themen: Erzählungen, Begründungen und längere Gespräche.',
difficultyLevel: 3, cefrLevel: 'B1-bridge', courseKind: 'continuation', prerequisiteCourseId: a2?.id || null,
isPublic: true, shareCode: crypto.randomBytes(8).toString('hex')
}
});
let created = 0;
for (const [blockIndex, [topic, goal]] of BLOCKS.entries()) {
for (let offset = 0; offset < 6; offset += 1) {
const lessonNumber = blockIndex * 6 + offset + 1;
const [, made] = await VocabCourseLesson.findOrCreate({
where: { courseId: course.id, lessonNumber },
defaults: {
courseId: course.id, chapterId: null, lessonNumber, weekNumber: blockIndex + 1, dayNumber: offset + 1,
title: `${topic}: ${NAMES[offset]}`, description: goal, lessonType: KINDS[offset],
didacticMode: offset === 5 ? 'checkpoint' : KINDS[offset] === 'listening' ? 'listening' : 'real_life_scenario',
phaseLabel: 'b1_bridge', blockNumber: blockIndex + 1, difficultyWeight: 3, newUnitTarget: offset === 5 ? 0 : 7,
reviewWeight: offset === 5 ? 75 : 35, isIntensiveReview: offset === 5, targetMinutes: 35, targetScorePercent: 80, requiresReview: offset === 5,
learningGoals: [goal, 'Eine begründete Antwort mit mindestens acht Sätzen formulieren.', 'Ein Gegenargument aufgreifen oder eine Rückfrage stellen.'],
corePatterns: [
{ target: 'Sa akong panan-aw, importante kini tungod kay…', gloss: 'Meiner Ansicht nach ist das wichtig, weil …' },
{ target: 'Bisan pa man, mahimo pud nga…', gloss: 'Trotzdem kann es auch sein, dass …' },
{ target: 'Kung mao kana ang kahimtang, mas maayo nga…', gloss: 'Wenn das die Situation ist, wäre es besser, dass …' },
{ target: 'Nasabtan nako, apan lahi ang akong kasinatian.', gloss: 'Ich verstehe das, aber meine Erfahrung ist anders.' }
],
grammarFocus: [{ title: 'Verknüpfte Gedanken', text: 'Verbinde Standpunkt, Grund und mögliche Folge in einem zusammenhängenden Beitrag.', example: 'Sa akong panan-aw, importante kini tungod kay…' }],
speakingPrompts: [{ title: 'Standpunkt entwickeln', prompt: `${goal} Nenne eine Meinung, zwei Gründe und reagiere auf eine mögliche Gegenposition.`, cue: 'Sa akong panan-aw… Bisan pa man…' }],
practicalTasks: [{ title: 'Echte Anwendung', text: 'Führe eine fünfminütige Unterhaltung mit einer Person oder dokumentiere, welche Stelle noch zu schwer war.' }]
}
});
if (made) created += 1;
}
}
console.log(`B1-Brücke ${course.id}: ${created} Lektionen angelegt.`);
}
main().then(() => sequelize.close()).catch((error) => { console.error(error); sequelize.close(); process.exitCode = 1; });

View File

@@ -606,15 +606,10 @@ function buildContextGapExercise(lesson, didactics, pattern) {
const scenarioPattern = didactics.corePatterns
.map((entry) => corePatternTarget(entry))
.find((candidate) => candidate && cue.includes(normalizeText(candidate).toLowerCase()));
// Do not hide a complete sentence behind one blank. Learners need enough
// visible context to know which word or short phrase belongs in the gap.
const gapExercise = scenarioPattern
? {
exerciseTypeId: 1,
title: `${lesson.title}: Kernmuster ergänzen`,
instruction: 'Fülle die Lücke mit dem passenden Bisaya-Ausdruck.',
questionData: { type: 'gap_fill', text: '{gap}', gaps: 1 },
answerData: { type: 'gap_fill', answers: [scenarioPattern] },
explanation: `Das Kernmuster lautet: "${scenarioPattern}".`
}
? buildGapExercise(lesson.title, scenarioPattern)
: buildGapExercise(lesson.title, pattern);
if (!gapExercise) return null;