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

@@ -84,6 +84,10 @@ class VocabController {
this.updateGrammarExercise = this._wrapWithUser((userId, req) => this.service.updateGrammarExercise(userId, req.params.exerciseId, req.body));
this.deleteGrammarExercise = this._wrapWithUser((userId, req) => this.service.deleteGrammarExercise(userId, req.params.exerciseId));
this.sendLessonAssistantMessage = this._wrapWithUser((userId, req) => this.service.sendLessonAssistantMessage(userId, req.params.lessonId, req.body), { successStatus: 201 });
this.startConversationSession = this._wrapWithUser((userId, req) => this.service.startConversationSession(userId, req.params.lessonId, req.body), { successStatus: 201 });
this.sendConversationTurn = this._wrapWithUser((userId, req) => this.service.sendConversationTurn(userId, req.params.sessionId, req.body), { successStatus: 201 });
this.completeConversationSession = this._wrapWithUser((userId, req) => this.service.completeConversationSession(userId, req.params.sessionId));
this.getListeningActivities = this._wrapWithUser((userId, req) => this.service.getListeningActivities(userId, req.params.lessonId, req.query));
}
_wrapWithUser(fn, { successStatus = 200 } = {}) {

View File

@@ -0,0 +1,54 @@
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.sequelize.query(`
ALTER TABLE community.vocab_course
ADD COLUMN IF NOT EXISTS cefr_level TEXT,
ADD COLUMN IF NOT EXISTS course_kind TEXT NOT NULL DEFAULT 'core',
ADD COLUMN IF NOT EXISTS prerequisite_course_id INTEGER NULL
REFERENCES community.vocab_course(id) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS community.vocab_conversation_session (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES community."user"(id) ON DELETE CASCADE,
lesson_id INTEGER NOT NULL REFERENCES community.vocab_course_lesson(id) ON DELETE CASCADE,
scenario JSONB NOT NULL DEFAULT '{}'::jsonb,
turns JSONB NOT NULL DEFAULT '[]'::jsonb,
summary JSONB NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS vocab_conversation_session_user_lesson_idx
ON community.vocab_conversation_session(user_id, lesson_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS community.vocab_listening_activity (
id SERIAL PRIMARY KEY,
lesson_id INTEGER NOT NULL REFERENCES community.vocab_course_lesson(id) ON DELETE CASCADE,
audio_url TEXT NOT NULL,
transcript TEXT NOT NULL,
translation TEXT NULL,
speaker_profile JSONB NOT NULL DEFAULT '{}'::jsonb,
speed TEXT NOT NULL DEFAULT 'natural',
questions JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS vocab_listening_activity_lesson_idx
ON community.vocab_listening_activity(lesson_id);
`, { transaction });
});
},
async down(queryInterface) {
await queryInterface.sequelize.query(`
DROP TABLE IF EXISTS community.vocab_listening_activity;
DROP TABLE IF EXISTS community.vocab_conversation_session;
ALTER TABLE community.vocab_course
DROP COLUMN IF EXISTS prerequisite_course_id,
DROP COLUMN IF EXISTS course_kind,
DROP COLUMN IF EXISTS cefr_level;
`);
}
};

View File

@@ -0,0 +1,22 @@
'use strict';
/** Repairs the already generated exercise that rendered as a bare blank. */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET question_data = '{"type":"gap_fill","text":"{gap} ka padulong?","gaps":1}'::jsonb,
answer_data = '{"type":"gap_fill","answers":["Asa"]}'::jsonb,
explanation = 'Das Kernmuster lautet: "Asa ka padulong?"'
FROM community.vocab_course_lesson AS lesson
WHERE exercise.lesson_id = lesson.id
AND lesson.lesson_number = 46
AND lesson.title = 'Fragen im Alltag vertiefen'
AND exercise.title = 'Fragen im Alltag vertiefen: Kernmuster ergänzen'
AND exercise.question_data->>'text' = '{gap}';
`);
},
async down() {
// The old exercise has no useful learner-visible prompt and is not restored.
}
};

View File

@@ -128,6 +128,8 @@ import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js
import VocabGrammarExercise from './community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js';
import VocabSrsItem from './community/vocab_srs_item.js';
import VocabConversationSession from './community/vocab_conversation_session.js';
import VocabListeningActivity from './community/vocab_listening_activity.js';
import CalendarEvent from './community/calendar_event.js';
import Campaign from './match3/campaign.js';
import Match3Level from './match3/level.js';
@@ -1188,6 +1190,13 @@ export default function setupAssociations() {
VocabCourse.hasMany(VocabSrsItem, { foreignKey: 'courseId', as: 'srsItems' });
VocabSrsItem.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' });
VocabCourseLesson.hasMany(VocabSrsItem, { foreignKey: 'lessonId', as: 'srsItems' });
VocabConversationSession.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(VocabConversationSession, { foreignKey: 'userId', as: 'vocabConversationSessions' });
VocabConversationSession.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' });
VocabCourseLesson.hasMany(VocabConversationSession, { foreignKey: 'lessonId', as: 'conversationSessions' });
VocabListeningActivity.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' });
VocabCourseLesson.hasMany(VocabListeningActivity, { foreignKey: 'lessonId', as: 'listeningActivities' });
// Calendar associations
CalendarEvent.belongsTo(User, { foreignKey: 'userId', as: 'user' });

View File

@@ -0,0 +1,25 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../utils/sequelize.js';
class VocabConversationSession extends Model {}
VocabConversationSession.init({
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
userId: { type: DataTypes.INTEGER, allowNull: false, field: 'user_id' },
lessonId: { type: DataTypes.INTEGER, allowNull: false, field: 'lesson_id' },
scenario: { type: DataTypes.JSONB, allowNull: false, defaultValue: {} },
turns: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] },
summary: { type: DataTypes.JSONB, allowNull: true },
status: { type: DataTypes.TEXT, allowNull: false, defaultValue: 'active' },
createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: 'created_at' },
updatedAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: 'updated_at' }
}, {
sequelize,
modelName: 'VocabConversationSession',
tableName: 'vocab_conversation_session',
schema: 'community',
timestamps: true,
underscored: true
});
export default VocabConversationSession;

View File

@@ -39,6 +39,22 @@ VocabCourse.init({
defaultValue: 1,
field: 'difficulty_level'
},
cefrLevel: {
type: DataTypes.TEXT,
allowNull: true,
field: 'cefr_level'
},
courseKind: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'core',
field: 'course_kind'
},
prerequisiteCourseId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'prerequisite_course_id'
},
isPublic: {
type: DataTypes.BOOLEAN,
allowNull: false,

View File

@@ -0,0 +1,25 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../utils/sequelize.js';
class VocabListeningActivity extends Model {}
VocabListeningActivity.init({
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
lessonId: { type: DataTypes.INTEGER, allowNull: false, field: 'lesson_id' },
audioUrl: { type: DataTypes.TEXT, allowNull: false, field: 'audio_url' },
transcript: { type: DataTypes.TEXT, allowNull: false },
translation: { type: DataTypes.TEXT, allowNull: true },
speakerProfile: { type: DataTypes.JSONB, allowNull: false, defaultValue: {} , field: 'speaker_profile' },
speed: { type: DataTypes.TEXT, allowNull: false, defaultValue: 'natural' },
questions: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] },
createdAt: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, field: 'created_at' }
}, {
sequelize,
modelName: 'VocabListeningActivity',
tableName: 'vocab_listening_activity',
schema: 'community',
timestamps: false,
underscored: true
});
export default VocabListeningActivity;

View File

@@ -158,6 +158,8 @@ import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js
import VocabGrammarExercise from './community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js';
import VocabSrsItem from './community/vocab_srs_item.js';
import VocabConversationSession from './community/vocab_conversation_session.js';
import VocabListeningActivity from './community/vocab_listening_activity.js';
import CalendarEvent from './community/calendar_event.js';
const models = {
@@ -322,6 +324,8 @@ const models = {
VocabGrammarExercise,
VocabGrammarExerciseProgress,
VocabSrsItem,
VocabConversationSession,
VocabListeningActivity,
// Calendar
CalendarEvent,

View File

@@ -61,6 +61,10 @@ router.delete('/lessons/:lessonId/progress', vocabController.resetLessonProgress
// Grammar Exercises
router.get('/grammar/exercise-types', vocabController.getExerciseTypes);
router.post('/lessons/:lessonId/assistant', vocabController.sendLessonAssistantMessage);
router.get('/lessons/:lessonId/listening-activities', vocabController.getListeningActivities);
router.post('/lessons/:lessonId/conversation-sessions', vocabController.startConversationSession);
router.post('/conversation-sessions/:sessionId/turns', vocabController.sendConversationTurn);
router.post('/conversation-sessions/:sessionId/complete', vocabController.completeConversationSession);
router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise);
router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson);
router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress);

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;

View File

@@ -8,6 +8,8 @@ import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
import VocabSrsItem from '../models/community/vocab_srs_item.js';
import VocabConversationSession from '../models/community/vocab_conversation_session.js';
import VocabListeningActivity from '../models/community/vocab_listening_activity.js';
import UserParamType from '../models/type/user_param.js';
import UserParam from '../models/community/user_param.js';
import { sequelize } from '../utils/sequelize.js';
@@ -765,6 +767,7 @@ export default class VocabService {
const modeDirectives = {
explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.',
practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.',
conversation: 'Führe ein zusammenhängendes Rollengespräch. Bleibe in deiner Rolle, antworte überwiegend in der Zielsprache und korrigiere nicht jeden Zug. Wenn der Nutzer um Hilfe bittet, gib genau einen kurzen Hinweis. Stelle eine natürliche Rückfrage, damit das Gespräch weitergeht.',
correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.'
};
@@ -2496,7 +2499,7 @@ export default class VocabService {
// ========== COURSE METHODS ==========
async createCourse(hashedUserId, { title, description, languageId, nativeLanguageId, difficultyLevel = 1, isPublic = false }) {
async createCourse(hashedUserId, { title, description, languageId, nativeLanguageId, difficultyLevel = 1, cefrLevel = null, courseKind = 'core', prerequisiteCourseId = null, isPublic = false }) {
const user = await this._getUserByHashedId(hashedUserId);
// Prüfe Zugriff auf Sprache
@@ -2511,6 +2514,9 @@ export default class VocabService {
languageId: Number(languageId),
nativeLanguageId: nativeLanguageId ? Number(nativeLanguageId) : null,
difficultyLevel: Number(difficultyLevel) || 1,
cefrLevel: cefrLevel ? String(cefrLevel).trim().slice(0, 24) : null,
courseKind: String(courseKind || 'core').trim().slice(0, 40) || 'core',
prerequisiteCourseId: prerequisiteCourseId ? Number(prerequisiteCourseId) : null,
isPublic: Boolean(isPublic),
shareCode
});
@@ -2727,7 +2733,7 @@ export default class VocabService {
return courseData;
}
async updateCourse(hashedUserId, courseId, { title, description, languageId, nativeLanguageId, difficultyLevel, isPublic }) {
async updateCourse(hashedUserId, courseId, { title, description, languageId, nativeLanguageId, difficultyLevel, cefrLevel, courseKind, prerequisiteCourseId, isPublic }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
@@ -2749,6 +2755,9 @@ export default class VocabService {
if (languageId !== undefined) updates.languageId = Number(languageId);
if (nativeLanguageId !== undefined) updates.nativeLanguageId = nativeLanguageId ? Number(nativeLanguageId) : null;
if (difficultyLevel !== undefined) updates.difficultyLevel = Number(difficultyLevel);
if (cefrLevel !== undefined) updates.cefrLevel = cefrLevel ? String(cefrLevel).trim().slice(0, 24) : null;
if (courseKind !== undefined) updates.courseKind = String(courseKind || 'core').trim().slice(0, 40) || 'core';
if (prerequisiteCourseId !== undefined) updates.prerequisiteCourseId = prerequisiteCourseId ? Number(prerequisiteCourseId) : null;
if (isPublic !== undefined) {
updates.isPublic = Boolean(isPublic);
// Generiere Share-Code wenn Kurs öffentlich wird
@@ -3293,7 +3302,7 @@ export default class VocabService {
throw err;
}
const mode = ['explain', 'practice', 'correct'].includes(payload?.mode) ? payload.mode : 'practice';
const mode = ['explain', 'practice', 'correct', 'conversation'].includes(payload?.mode) ? payload.mode : 'practice';
const history = this._sanitizeAssistantHistory(payload?.history);
const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
@@ -3318,7 +3327,8 @@ export default class VocabService {
const temperatureByMode = {
explain: 0.4,
practice: 0.5,
correct: 0.1
correct: 0.1,
conversation: 0.45
};
const temperature = Number.isFinite(temperatureByMode[mode]) ? temperatureByMode[mode] : 0.5;
@@ -3418,6 +3428,97 @@ export default class VocabService {
};
}
_normalizeConversationScenario(value) {
const source = value && typeof value === 'object' ? value : {};
const allowed = ['title', 'role', 'goal', 'level', 'difficulty'];
return Object.fromEntries(allowed
.map((key) => [key, String(source[key] || '').trim().slice(0, 240)])
.filter(([, text]) => text));
}
async startConversationSession(hashedUserId, lessonId, payload = {}) {
const user = await this._getUserByHashedId(hashedUserId);
await this.getLesson(hashedUserId, lessonId);
const scenario = this._normalizeConversationScenario(payload.scenario);
if (!scenario.title || !scenario.role || !scenario.goal) {
const err = new Error('Bitte wähle Titel, Rolle und Gesprächsziel für das Gespräch.');
err.status = 400;
throw err;
}
const session = await VocabConversationSession.create({
userId: user.id,
lessonId: Number(lessonId),
scenario,
turns: []
});
return session.get({ plain: true });
}
async sendConversationTurn(hashedUserId, sessionId, payload = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const session = await VocabConversationSession.findOne({ where: { id: Number(sessionId), userId: user.id } });
if (!session || session.status !== 'active') {
const err = new Error('Diese Gesprächssitzung ist nicht mehr aktiv.');
err.status = 404;
throw err;
}
const text = String(payload.message || '').trim().slice(0, 2000);
if (!text) {
const err = new Error('Bitte gib einen Gesprächsbeitrag ein.');
err.status = 400;
throw err;
}
const turns = Array.isArray(session.turns) ? session.turns : [];
const scenario = session.scenario || {};
const scenarioHint = `Rollengespräch: ${scenario.title || 'Alltagssituation'}; deine Rolle: ${scenario.role || 'Gesprächspartner'}; Ziel: ${scenario.goal || 'Gespräch führen'}; Niveau: ${scenario.level || 'A2'}.`;
const result = await this.sendLessonAssistantMessage(hashedUserId, session.lessonId, {
mode: 'conversation',
message: `${scenarioHint}\n\nMein Beitrag: ${text}`,
history: turns.slice(-12)
});
const nextTurns = [...turns, { role: 'user', content: text }, { role: 'assistant', content: result.reply }].slice(-30);
await session.update({ turns: nextTurns });
return { session: session.get({ plain: true }), reply: result.reply };
}
async completeConversationSession(hashedUserId, sessionId) {
const user = await this._getUserByHashedId(hashedUserId);
const session = await VocabConversationSession.findOne({ where: { id: Number(sessionId), userId: user.id } });
if (!session) {
const err = new Error('Gesprächssitzung nicht gefunden.');
err.status = 404;
throw err;
}
const turns = Array.isArray(session.turns) ? session.turns : [];
const userTurns = turns.filter((turn) => turn.role === 'user');
const summary = {
goal: session.scenario?.goal || '',
userTurns: userTurns.length,
completedAt: new Date().toISOString(),
nextStep: userTurns.length >= 6
? 'Wiederhole das Gespräch mit weniger Hilfen oder einem neuen Ziel.'
: 'Führe das Gespräch weiter, bis du mindestens sechs eigene Beiträge formuliert hast.'
};
await session.update({ status: 'completed', summary });
return session.get({ plain: true });
}
async getListeningActivities(hashedUserId, lessonId, { includeTranscript = false } = {}) {
await this.getLesson(hashedUserId, lessonId);
const activities = await VocabListeningActivity.findAll({
where: { lessonId: Number(lessonId) },
order: [['id', 'ASC']]
});
return activities.map((activity) => {
const plain = activity.get({ plain: true });
if (!(includeTranscript === true || includeTranscript === 'true')) {
delete plain.transcript;
delete plain.translation;
}
return plain;
});
}
/**
* Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
*/

View File

@@ -0,0 +1,129 @@
# Bisaya-Ausbauplan: von A1/A2 zu praxisnahem B1
## Zielbild
Der bestehende Kurs bis Lektion 153 bleibt als **Phase 1: Grundkommunikation (A1 bis A2.1)** erhalten. Der Ausbau führt nicht zu einem formalen Zertifikat, sondern zu einem überprüfbaren, praxisnahen Ziel: Lernende können längere vertraute Gespräche führen, natürliches Bisaya schrittweise besser verstehen und sich in neuen Alltagssituationen verständlich ausdrücken.
**Ziel nach dem Ausbau:** belastbares A2.2, mit B1-Teilkompetenzen in vertrauten Themen. Ein vollständiges B1 wird erst beansprucht, wenn freie Produktion, Hörverstehen und echte Gesprächspraxis separat nachgewiesen sind.
## Produktumfang und Reihenfolge
| Ausbaustufe | Umfang | Zielniveau | Schwerpunkt |
| --- | ---: | --- | --- |
| Anschlusskurs A2 | 72 Lektionen, 12 Wochen | A2.1 → A2.2 | Wortschatz, variablere Satzmuster, Hörverstehen |
| Phase 3: Gesprächslabor | 36 Sitzungen, flexibel | A2.2 | längere KI-Dialoge, Korrektur und Transfer |
| Phase 4: B1-Brücke | 96 Lektionen, 16 Wochen | B1-Teilkompetenzen | Meinungen, Erzählungen, komplexere Situationen |
| Praxisstrang | fortlaufend | nachweisbare Anwendung | Hörmaterial und Gespräche mit Menschen |
Jede Inhaltswoche besteht aus vier Aufbaulektionen, einer produktiven Szenenlektion, einer Hörlektion und einer Wiederholungs-/Checkpoint-Lektion. Neue Inhalte bleiben bei etwa 5565 %, der Rest ist gezielte Wiederaufnahme.
Der A2- und der B1-Brückenpfad werden als **eigenständige Anschlusskurse** angelegt, nicht an Lektion 153 angehängt. So bleiben Fortschritt, Niveauanzeige, Freischaltung und Abschlussdiagnosen eindeutig. Die Lektionen jedes Anschlusskurses beginnen wieder bei 1.
## Anschlusskurs A2 (72 Lektionen)
### Kapitel 13: Alltag breiter und genauer ausdrücken (Lektionen 118)
- Wohnen, Reparaturen, Arbeit, Schule, Termine und digitale Kommunikation.
- Wortschatzfelder: Eigenschaften, Mengen, Häufigkeit, Gründe, Wünsche, Probleme und Lösungen.
- Grammatik nur funktional: Aspektformen in Gegenwart/Vergangenheit/Zukunft, Fragewörter und Negation in echten Dialogen.
- Abschluss: einen Tagesablauf, ein Problem und einen Termin in 810 Sätzen erklären.
### Kapitel 46: Beziehungen, Meinung und Erzählung (Lektionen 1936)
- Familie, Freundschaft, Konflikte, Gefühle, Einladungen, Pläne und Entscheidungen.
- Neue Gesprächswerkzeuge: zustimmen, höflich widersprechen, begründen, um Klärung bitten, Thema wechseln und zusammenfassen.
- Grammatik: Pronomen und Fokusmuster in häufigen Satzrahmen; Verknüpfer wie „weil“, „deshalb“, „zuerst/danach“, „obwohl/aber“ zuerst als produktive Satzbausteine.
- Abschluss: ein 35-minütiges geführtes Gespräch über einen Plan oder ein Missverständnis.
### Kapitel 79: Mobilität, Öffentlichkeit und unvorhergesehene Situationen (Lektionen 3754)
- Reisen, Unterkunft, Telefon, Lieferungen, Bank, Dokumente, Behörde, Gesundheit und Sicherheit.
- Grammatik: Bitten nach Höflichkeitsgrad, Bedingungen, Absichten und Folgen; Kontraste ähnlicher Formen statt isolierter Regelblöcke.
- Abschluss: eine mehrstufige Erledigung planen, Änderungen erklären und Hilfe organisieren.
### Kapitel 1012: A2-Transfer und Diagnose (Lektionen 5572)
- Szenenwechsel: Markt → Familie → Arzt → Behörde → Einladung; keine Themenreihenfolge als Stütze.
- Freie Kurzmonologe (1012 Sätze), Rollenspiele und Fehlercluster-Training.
- A2-Abschlussprüfung: Lese-/Hörverstehen, zwei schriftliche Antworten, ein KI-Rollenspiel und ein realer Gesprächsauftrag.
## Anschlusskurs B1-Brücke (96 Lektionen)
Die B1-Brücke startet nur, wenn die A2-Abschlussdiagnose in Abruf, Produktion und Hörverstehen stabil ist. Sie soll keine Vokabelliste mit B1-Aufkleber sein.
| Kapitel | Schwerpunkt | Sprachhandlung |
| --- | --- | --- |
| 2628 | Arbeit, Bildung, Wohnen, Gesellschaft | vergleichen, erläutern, entscheiden |
| 2931 | Medien, Reise, Kultur, Erfahrungen | zusammenhängend erzählen und nachfragen |
| 3234 | Meinung, Konflikt, Hypothesen | begründen, höflich widersprechen, verhandeln |
| 3537 | B1-Transfer | längere Gespräche, spontane Themenwechsel, Fehlerreduktion |
Grammatik kommt in dieser Phase in wiederkehrenden Kommunikationsaufgaben vor: Fokus/Aspekt bei Erzählungen, Bedingungen und Möglichkeiten bei Planung, komplexere Verknüpfungen bei Begründungen. Jede neue Form braucht zunächst ein verständliches Muster, anschließend variierte Übungen und zuletzt freie Anwendung.
## KI-Gesprächslabor
Der vorhandene Assistent soll vom Kurz-Chat zum sitzungsbasierten Gesprächstrainer werden.
1. **Szenenstart:** Lernende wählen Thema, Rolle, Zielniveau, Schwierigkeitsgrad und Gesprächsziel (z. B. Termin verschieben, Familienkonflikt klären).
2. **Gesprächsvertrag:** Der Assistent bleibt in Rolle, verwendet das Material der aktuellen und früherer freigeschalteter Lektionen, stellt echte Rückfragen und wechselt nicht ungefragt ins Deutsche.
3. **Steuerung während des Gesprächs:** Buttons für „langsamer“, „anders sagen“, „Hinweis“, „Wiederholen“ und „deutsche Erklärung“. Das verhindert, dass die KI eine unverständliche Antwort einfach fortsetzt.
4. **Nachbesprechung:** maximal drei relevante Korrekturen, bessere Formulierungen, neu gelernte Ausdrücke und ein persönliches Wiederholungspaket.
5. **Messung:** Gesprächsdauer, selbst erzeugte Zielsprachsätze, Verständigungsreparaturen, wiederkehrende Fehler und erreichte Gesprächsziele. Qualität wird nicht aus bloßer Nachrichtenanzahl abgeleitet.
Empfohlene Stufen: 68 Züge mit Satzhilfen (A2.1), 1014 Züge mit optionalen Hilfen (A2.2), 1520 Züge mit Themawechsel und anschließender Zusammenfassung (B1-Brücke).
## Hörverstehen und natürliche Geschwindigkeit
Natürliches Verstehen wird als eigener Lernstrang umgesetzt; TTS allein genügt nicht.
1. **Stufe 1 verständlich:** kurze, deutlich eingesprochene Dialoge; Transkript nach dem ersten Hören, Satzwiederholung und Bedeutungsfragen.
2. **Stufe 2 variabel:** zwei Sprecher, moderate natürliche Geschwindigkeit, Füllwörter und einfache Umgangssprache; Transkript erst nach einem zweiten Versuch.
3. **Stufe 3 authentisch:** mit Zustimmung lizenzierte oder eigens aufgezeichnete Gespräche verschiedener Sprecher und Regionen; Aufgaben zum Gesamtinhalt, zu Details und zur Reaktion.
4. **Sprachpraxis:** wöchentlicher Gesprächsauftrag mit einer Person, optional über einen moderierten Tandem-/Familienmodus. Vorher Gesprächsplan, nachher Selbstreflexion; keine automatisierte Behauptung, echte Konversation sei vollständig durch KI ersetzt.
Jede Hörlektion speichert mindestens Audioquelle/Lizenz, Sprecherprofil, ungefähres Sprechtempo, Transkript, Glossar, Aufgaben und die freigeschaltete Transkript-Hilfe. Inhalte aus sozialen Medien oder von Dritten werden nur mit klarer Nutzungserlaubnis eingebunden.
## Technische Umsetzung
### Datenmodell und Inhalt
- Bestehende Lektionen verwenden bereits `phase_label`, `block_number`, `didactic_mode`, `grammar_focus`, `speaking_prompts`, `practical_tasks` und `audio_url` in `backend/models/community/vocab_course_lesson.js`.
- `vocab_course` wird durch eine Migration um `cefr_level`, `course_kind` und optional `prerequisite_course_id` ergänzt. Das bisherige numerische `difficulty_level` bleibt aus Kompatibilitätsgründen erhalten.
- Neue Phasen als eigene, wiederholbar ausführbare Erweiterungsskripte nach dem Muster von `backend/scripts/extend-bisaya-course-phase5.js`; Lektionen nicht per Hand in der Datenbank pflegen.
- Für Hörlektionen das Modell ergänzen: Audio-Metadaten und Transkript gehören in eine eigene, versionsfähige Tabelle statt in `audio_url` oder unstrukturiertes JSON. Dazu Übungen für Gesamtverständnis, Detailverständnis und Diktat/Segmentierung.
- Für Gesprächssitzungen eine eigene Tabelle mit Nutzer, Lektion/Szenario, Verlauf, Ziel, Ergebnis, Fehlerzusammenfassung, Zeitstempel und Lösch-/Aufbewahrungsregeln. Nachrichten müssen nicht dauerhaft gespeichert werden, wenn die Person das nicht möchte.
### Backend
- Den bestehenden Endpunkt `POST /api/vocab/lessons/:lessonId/assistant` und den Prompt-Builder in `backend/services/vocabService.js` um einen strikt validierten Modus `conversation` ergänzen; die bisherigen Modi `practice`, `explain` und `correct` bleiben unverändert.
- Szenario-Parameter serverseitig gegen das freigeschaltete Kursmaterial prüfen. Das Systemprompt erhält Gesprächsziel, Rolle, Zielschwierigkeit, bekannte Kernmuster und klare Regeln für Korrekturzeitpunkt sowie Zielsprachanteil.
- Neue Endpunkte für Sitzung starten/fortsetzen/abschließen und für die strukturierte Nachbesprechung. Rate Limits, Zeitlimits und die bestehende nutzereigene LLM-Konfiguration beibehalten.
- Hördateien aus einem kontrollierten Storage ausliefern; Lizenz- und Transkriptmetadaten prüfen, bevor Inhalte veröffentlicht werden.
### Frontend
- `frontend/src/views/social/VocabLessonView.vue` erhält neben dem vorhandenen Kurzassistenten einen Gesprächslabor-Einstieg, eine klare Sitzungsansicht, Hilfebedienelemente und die Nachbesprechung.
- Hörlektionen erhalten einen Audio-Player mit 0,75×/1×/1,1×, Wiederholung von Segmenten, verzögertem Transkript und den drei Aufgabenarten. Keine automatische Texteinblendung vor dem ersten Hörversuch.
- Alle neuen Texte in die vorhandenen Locale-Dateien aufnehmen; Deutsch und Cebuano zuerst vollständig, weitere App-Sprachen mit Fallback.
## Lieferung in vier Inkrementen
1. **Kursgrundlage:** Phase-2-Lehrplan finalisieren, 154171 als Pilot entwickeln, didaktische Daten und Übungen automatisiert prüfen.
2. **Gesprächslabor MVP:** ein Szenario pro Pilotkapitel, kurze gespeicherte Sitzung, Zielerreichung und Nachbesprechung; mit lokalem Ollama und OpenAI-kompatiblen Endpunkten testen.
3. **Hör-MVP:** 20 lizenzierte Dialoge mit Metadaten, Transkript-Freischaltung und Auswertung; erst danach authentischere Sprecher-/Tempo-Varianten skalieren.
4. **A2-Abschluss und B1-Brücke:** restliche A2-Kapitel, Diagnose und nur bei positiver Pilotnutzung die B1-Module erstellen.
## Qualitäts- und Abnahmekriterien
- Jede Inhaltslektion hat mindestens drei messbare Lernziele, vier Kernmuster, eine produktive Aufgabe und eine Transferaufgabe.
- Jede Grammatikform wird in mindestens drei Kontexten wiederaufgenommen und nicht allein über Multiple Choice geprüft.
- Ein KI-Gespräch gilt nur als abgeschlossen, wenn Gesprächsziel, Lernendenanteil und höchstens drei priorisierte Korrekturen dokumentiert sind.
- Hörverstehen wird ohne sichtbares Transkript begonnen; mindestens 70 % im Gesamtverständnis und 60 % in Details sind vor dem nächsthöheren Tempo anzustreben.
- Der A2-Übergang setzt über mehrere Wochen stabile Ergebnisse in SRS, freier Produktion, Hörverstehen und mindestens zwei menschlichen Gesprächsaufträgen voraus.
## Prüfung bei der Umsetzung
- Erweiterungsskript zunächst in einer Entwicklungsdatenbank ausführen und Idempotenz testen: kein doppelter Kurs, keine doppelten Lektionen, korrekte Reihenfolge und Metadaten.
- Backend-Tests für Berechtigung, ungültige Szenarioparameter, begrenzten Verlauf, Sitzungsabschluss und LLM-Ausfall.
- Frontend-Tests für Start/Fortsetzen/Abschluss einer Gesprächssitzung, Hilfen, Transkript-Sperre, Geschwindigkeit und Fortschrittsanzeige.
- Manuelle fachliche Prüfung mit mindestens zwei Cebuano-Muttersprachlern: Natürlichkeit, regionale Varianten, Höflichkeitsniveau, Korrektheit der Transkripte und Verständlichkeit bei unterschiedlichem Tempo.

View File

@@ -12,6 +12,7 @@
<div class="course-info surface-card">
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
<span v-if="course.cefrLevel">GER: {{ course.cefrLevel }}</span>
<span v-if="course.isPublic">{{ $t('socialnetwork.vocab.courses.public') }}</span>
<span v-if="course.shareCode && isOwner" class="share-code">
{{ $t('socialnetwork.vocab.courses.shareCode') }}: <code>{{ course.shareCode }}</code>

View File

@@ -545,6 +545,40 @@
</div>
</div>
</details>
<section v-if="listeningActivities.length" class="didactic-card listening-lab">
<h4>Hörtraining</h4>
<article v-for="activity in listeningActivities" :key="activity.id" class="listening-lab__activity">
<p><strong>{{ activity.speed === 'natural' ? 'Natürliches Tempo' : activity.speed }}</strong></p>
<audio :src="activity.audioUrl" controls preload="metadata" />
<button type="button" class="button-secondary" @click="toggleTranscript(activity)">
{{ listeningTranscripts[activity.id] ? 'Transkript ausblenden' : 'Transkript anzeigen' }}
</button>
<p v-if="listeningTranscripts[activity.id]" class="listening-lab__transcript">{{ listeningTranscripts[activity.id].transcript }}</p>
</article>
</section>
<section v-if="assistantAvailable" class="didactic-card conversation-lab">
<h4>Gesprächslabor</h4>
<p>Übe ein längeres Rollengespräch. Korrekturen kommen erst nach der Sitzung.</p>
<template v-if="!conversationSession">
<label><span>Rolle der KI</span><input v-model="conversationScenario.role" placeholder="z. B. Nachbarin" /></label>
<label><span>Gesprächsziel</span><input v-model="conversationScenario.goal" placeholder="z. B. einen Termin verschieben" /></label>
<button type="button" @click="startConversation" :disabled="conversationSubmitting">Gespräch starten</button>
</template>
<template v-else>
<div class="language-assistant-chat">
<article v-for="(turn, index) in conversationSession.turns" :key="`conversation-${index}`" class="assistant-message" :class="`assistant-message--${turn.role}`">
<strong>{{ turn.role === 'assistant' ? 'Gesprächspartner' : 'Du' }}</strong><p>{{ turn.content }}</p>
</article>
</div>
<textarea v-model="conversationInput" rows="3" placeholder="Dein Gesprächsbeitrag" />
<button type="button" @click="sendConversationTurn" :disabled="conversationSubmitting || !conversationInput.trim()">Antworten</button>
<button type="button" class="button-secondary" @click="completeConversation" :disabled="conversationSubmitting">Gespräch beenden</button>
<p v-if="conversationSession.summary">{{ conversationSession.summary.nextStep }}</p>
</template>
<p v-if="conversationError" class="form-error">{{ conversationError }}</p>
</section>
</div>
<!-- Übungen-Tab (Kapitel-Prüfung) -->
@@ -1163,6 +1197,13 @@ export default {
assistantWaitElapsedSeconds: 0,
assistantWaitTimer: null,
isAssistantFocused: false,
listeningActivities: [],
listeningTranscripts: {},
conversationSession: null,
conversationInput: '',
conversationScenario: { role: '', goal: '', title: '', level: 'A2', difficulty: 'guided' },
conversationSubmitting: false,
conversationError: '',
nextLessonId: null,
showCompletionDialog: false,
showErrorDialog: false,
@@ -2798,6 +2839,11 @@ export default {
this.assistantMessages = [];
this.assistantInput = '';
this.assistantError = '';
this.listeningActivities = [];
this.listeningTranscripts = {};
this.conversationSession = null;
this.conversationInput = '';
this.conversationError = '';
this.exerciseRetryPending = false;
this.exerciseRetryPendingSinceAttempts = 0;
this.exerciseSequentialIndex = 0;
@@ -2842,6 +2888,7 @@ export default {
try {
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}`);
this.lesson = res.data;
await this.loadListeningActivities();
await this.loadCourseLanguageNames();
await this.loadCourseProgressForBoost();
debugLog('[VocabLessonView] Geladene Lektion:', this.lesson?.id, this.lesson?.title);
@@ -2963,6 +3010,71 @@ export default {
openLanguageAssistantSettings() {
this.$router.push('/settings/language-assistant');
},
async loadListeningActivities() {
try {
const { data } = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/listening-activities`);
this.listeningActivities = Array.isArray(data) ? data : [];
} catch (e) {
this.listeningActivities = [];
}
},
async toggleTranscript(activity) {
if (this.listeningTranscripts[activity.id]) {
const next = { ...this.listeningTranscripts };
delete next[activity.id];
this.listeningTranscripts = next;
return;
}
try {
const { data } = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/listening-activities`, { params: { includeTranscript: true } });
const full = (data || []).find((item) => item.id === activity.id);
if (full?.transcript) this.listeningTranscripts = { ...this.listeningTranscripts, [activity.id]: full };
} catch (e) {
// Die Lektion bleibt auch ohne Transkript nutzbar.
}
},
async startConversation() {
if (!this.conversationScenario.role.trim() || !this.conversationScenario.goal.trim()) return;
this.conversationSubmitting = true;
this.conversationError = '';
try {
const { data } = await apiClient.post(`/api/vocab/lessons/${this.lessonId}/conversation-sessions`, {
scenario: { ...this.conversationScenario, title: this.lesson?.title || 'Alltagssituation' }
});
this.conversationSession = data;
} catch (e) {
this.conversationError = e.response?.data?.error || e.message;
} finally {
this.conversationSubmitting = false;
}
},
async sendConversationTurn() {
const message = this.conversationInput.trim();
if (!message || !this.conversationSession) return;
this.conversationSubmitting = true;
this.conversationError = '';
try {
const { data } = await apiClient.post(`/api/vocab/conversation-sessions/${this.conversationSession.id}/turns`, { message });
this.conversationSession = data.session;
this.conversationInput = '';
} catch (e) {
this.conversationError = e.response?.data?.error || e.message;
} finally {
this.conversationSubmitting = false;
}
},
async completeConversation() {
if (!this.conversationSession) return;
this.conversationSubmitting = true;
try {
const { data } = await apiClient.post(`/api/vocab/conversation-sessions/${this.conversationSession.id}/complete`);
this.conversationSession = data;
} catch (e) {
this.conversationError = e.response?.data?.error || e.message;
} finally {
this.conversationSubmitting = false;
}
},
buildAssistantPrompt(preset) {
const lessonTitle = this.lesson?.title || this.$t('socialnetwork.vocab.courses.thisLesson');
const firstPattern = this.lessonDidactics.corePatterns?.[0];