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 d9397f741f
commit f9c884b3cf
16 changed files with 680 additions and 12 deletions

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
*/