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:
@@ -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 } = {}) {
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
|
||||
25
backend/models/community/vocab_conversation_session.js
Normal file
25
backend/models/community/vocab_conversation_session.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
25
backend/models/community/vocab_listening_activity.js
Normal file
25
backend/models/community/vocab_listening_activity.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
90
backend/scripts/create-bisaya-a2-course.js
Normal file
90
backend/scripts/create-bisaya-a2-course.js
Normal 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.', 'Naa’y 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; });
|
||||
77
backend/scripts/create-bisaya-b1-bridge-course.js
Normal file
77
backend/scripts/create-bisaya-b1-bridge-course.js
Normal 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; });
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user