diff --git a/backend/controllers/navigationController.js b/backend/controllers/navigationController.js
index 420d73f..8f422dc 100644
--- a/backend/controllers/navigationController.js
+++ b/backend/controllers/navigationController.js
@@ -214,6 +214,10 @@ const menuStructure = {
visible: ["all"],
path: "/settings/account"
},
+ languageAssistant: {
+ visible: ["all"],
+ path: "/settings/language-assistant"
+ },
personal: {
visible: ["all"],
path: "/settings/personal"
diff --git a/backend/controllers/settingsController.js b/backend/controllers/settingsController.js
index 8255b47..7e41316 100644
--- a/backend/controllers/settingsController.js
+++ b/backend/controllers/settingsController.js
@@ -185,6 +185,38 @@ class SettingsController {
res.status(500).json({ error: 'Internal server error' });
}
}
+
+ async getLlmSettings(req, res) {
+ try {
+ const hashedUserId = req.headers.userid;
+ const data = await settingsService.getLlmSettings(hashedUserId);
+ res.status(200).json(data);
+ } catch (error) {
+ console.error('Error retrieving LLM settings:', error);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ }
+
+ async saveLlmSettings(req, res) {
+ const schema = Joi.object({
+ baseUrl: Joi.string().allow('').optional(),
+ model: Joi.string().allow('').optional(),
+ enabled: Joi.boolean().optional(),
+ apiKey: Joi.string().allow('').optional(),
+ clearKey: Joi.boolean().optional()
+ });
+ const { error, value } = schema.validate(req.body || {});
+ if (error) {
+ return res.status(400).json({ error: error.details[0].message });
+ }
+ try {
+ await settingsService.saveLlmSettings(req.headers.userid, value);
+ res.status(200).json({ success: true });
+ } catch (err) {
+ console.error('Error saving LLM settings:', err);
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ }
}
export default SettingsController;
diff --git a/backend/migrations/20260325000000-add-vocab-lesson-didactics.cjs b/backend/migrations/20260325000000-add-vocab-lesson-didactics.cjs
new file mode 100644
index 0000000..cc816dc
--- /dev/null
+++ b/backend/migrations/20260325000000-add-vocab-lesson-didactics.cjs
@@ -0,0 +1,36 @@
+/* eslint-disable */
+'use strict';
+
+module.exports = {
+ async up(queryInterface) {
+ await queryInterface.sequelize.query(`
+ ALTER TABLE community.vocab_course_lesson
+ ADD COLUMN IF NOT EXISTS learning_goals JSONB,
+ ADD COLUMN IF NOT EXISTS core_patterns JSONB,
+ ADD COLUMN IF NOT EXISTS grammar_focus JSONB,
+ ADD COLUMN IF NOT EXISTS speaking_prompts JSONB,
+ ADD COLUMN IF NOT EXISTS practical_tasks JSONB;
+ `);
+
+ await queryInterface.sequelize.query(`
+ INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
+ ('dialog_completion', 'Dialogergänzung'),
+ ('situational_response', 'Situative Antwort'),
+ ('pattern_drill', 'Muster-Drill'),
+ ('reading_aloud', 'Lautlese-Übung'),
+ ('speaking_from_memory', 'Freies Sprechen')
+ ON CONFLICT (name) DO NOTHING;
+ `);
+ },
+
+ async down(queryInterface) {
+ await queryInterface.sequelize.query(`
+ ALTER TABLE community.vocab_course_lesson
+ DROP COLUMN IF EXISTS practical_tasks,
+ DROP COLUMN IF EXISTS speaking_prompts,
+ DROP COLUMN IF EXISTS grammar_focus,
+ DROP COLUMN IF EXISTS core_patterns,
+ DROP COLUMN IF EXISTS learning_goals;
+ `);
+ }
+};
diff --git a/backend/models/community/vocab_course_lesson.js b/backend/models/community/vocab_course_lesson.js
index bf8a755..62df690 100644
--- a/backend/models/community/vocab_course_lesson.js
+++ b/backend/models/community/vocab_course_lesson.js
@@ -58,6 +58,31 @@ VocabCourseLesson.init({
allowNull: true,
field: 'cultural_notes'
},
+ learningGoals: {
+ type: DataTypes.JSONB,
+ allowNull: true,
+ field: 'learning_goals'
+ },
+ corePatterns: {
+ type: DataTypes.JSONB,
+ allowNull: true,
+ field: 'core_patterns'
+ },
+ grammarFocus: {
+ type: DataTypes.JSONB,
+ allowNull: true,
+ field: 'grammar_focus'
+ },
+ speakingPrompts: {
+ type: DataTypes.JSONB,
+ allowNull: true,
+ field: 'speaking_prompts'
+ },
+ practicalTasks: {
+ type: DataTypes.JSONB,
+ allowNull: true,
+ field: 'practical_tasks'
+ },
targetMinutes: {
type: DataTypes.INTEGER,
allowNull: true,
diff --git a/backend/routers/settingsRouter.js b/backend/routers/settingsRouter.js
index dcb203d..ee3cc6f 100644
--- a/backend/routers/settingsRouter.js
+++ b/backend/routers/settingsRouter.js
@@ -19,5 +19,7 @@ router.post('/setinterest', authenticate, settingsController.addUserInterest.bin
router.get('/removeinterest/:id', authenticate, settingsController.removeInterest.bind(settingsController));
router.get('/visibilities', authenticate, settingsController.getVisibilities.bind(settingsController));
router.post('/update-visibility', authenticate, settingsController.updateVisibility.bind(settingsController));
+router.get('/llm', authenticate, settingsController.getLlmSettings.bind(settingsController));
+router.post('/llm', authenticate, settingsController.saveLlmSettings.bind(settingsController));
export default router;
diff --git a/backend/scripts/add-bisaya-week1-lessons.js b/backend/scripts/add-bisaya-week1-lessons.js
index b0510a5..6288709 100644
--- a/backend/scripts/add-bisaya-week1-lessons.js
+++ b/backend/scripts/add-bisaya-week1-lessons.js
@@ -19,6 +19,19 @@ const LESSONS_TO_ADD = [
title: 'Woche 1 - Wiederholung',
description: 'Wiederhole alle Inhalte der ersten Woche',
culturalNotes: 'Wiederholung ist der Schlüssel zum Erfolg!',
+ learningGoals: [
+ 'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
+ 'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
+ 'Eine kurze Alltagssequenz frei sprechen.'
+ ],
+ corePatterns: ['Kumusta ka?', 'Palangga taka.', 'Nikaon na ka?', 'Wala ko kasabot.'],
+ speakingPrompts: [
+ {
+ title: 'Freie Wiederholung',
+ prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.',
+ cue: 'Kumusta ka? Palangga taka. Nikaon na ka?'
+ }
+ ],
targetMinutes: 30,
targetScorePercent: 80,
requiresReview: false
@@ -31,6 +44,12 @@ const LESSONS_TO_ADD = [
title: 'Woche 1 - Vokabeltest',
description: 'Teste dein Wissen aus Woche 1',
culturalNotes: null,
+ learningGoals: [
+ 'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
+ 'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
+ 'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
+ ],
+ corePatterns: ['Kumusta', 'Salamat', 'Lami', 'Mingaw ko nimo'],
targetMinutes: 15,
targetScorePercent: 80,
requiresReview: true
@@ -89,6 +108,9 @@ async function addBisayaWeek1Lessons() {
dayNumber: lessonData.dayNumber,
lessonType: lessonData.lessonType,
culturalNotes: lessonData.culturalNotes,
+ learningGoals: lessonData.learningGoals || [],
+ corePatterns: lessonData.corePatterns || [],
+ speakingPrompts: lessonData.speakingPrompts || [],
targetMinutes: lessonData.targetMinutes,
targetScorePercent: lessonData.targetScorePercent,
requiresReview: lessonData.requiresReview
diff --git a/backend/scripts/apply-bisaya-course-refresh.js b/backend/scripts/apply-bisaya-course-refresh.js
new file mode 100644
index 0000000..266839f
--- /dev/null
+++ b/backend/scripts/apply-bisaya-course-refresh.js
@@ -0,0 +1,199 @@
+#!/usr/bin/env node
+/**
+ * Spielt die überarbeiteten Bisaya-Kursinhalte ein und setzt den Lernfortschritt zurück.
+ *
+ * Verwendung:
+ * node backend/scripts/apply-bisaya-course-refresh.js
+ */
+
+import { sequelize } from '../utils/sequelize.js';
+import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
+import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
+import VocabCourseProgress from '../models/community/vocab_course_progress.js';
+import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
+import { Op } from 'sequelize';
+
+const LESSON_DIDACTICS = {
+ 'Begrüßungen & Höflichkeit': {
+ learningGoals: [
+ 'Einfache Begrüßungen verstehen und selbst verwenden.',
+ 'Höfliche Reaktionen wie Danke und Bitte passend einsetzen.',
+ 'Ein kurzes Begrüßungs-Mini-Gespräch laut üben.'
+ ],
+ corePatterns: ['Kumusta ka?', 'Maayo ko.', 'Salamat.', 'Palihug.'],
+ grammarFocus: [
+ { title: 'Kurzantworten mit ko', text: 'Mit "ko" sprichst du über dich selbst: "Maayo ko."', example: 'Maayo ko. = Mir geht es gut.' }
+ ],
+ speakingPrompts: [
+ { title: 'Mini-Gespräch', prompt: 'Begrüße eine Person, frage nach dem Befinden und reagiere höflich.', cue: 'Kumusta ka? Maayo ko. Salamat.' }
+ ],
+ practicalTasks: [{ title: 'Alltag', text: 'Sprich die Begrüßung dreimal laut und variiere die Antwort.' }]
+ },
+ 'Familienwörter': {
+ learningGoals: [
+ 'Die wichtigsten Familienbezeichnungen sicher erkennen.',
+ 'Familienmitglieder mit respektvollen Wörtern ansprechen.',
+ 'Kurze Sätze über die eigene Familie bilden.'
+ ],
+ corePatterns: ['Si Nanay', 'Si Tatay', 'Kuya nako', 'Ate nako'],
+ grammarFocus: [
+ { title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' }
+ ],
+ speakingPrompts: [
+ { title: 'Meine Familie', prompt: 'Stelle zwei Familienmitglieder mit einem kurzen Satz vor.', cue: 'Si Nanay. Si Kuya.' }
+ ],
+ practicalTasks: [{ title: 'Familienpraxis', text: 'Nenne laut fünf Familienwörter und bilde danach zwei Mini-Sätze.' }]
+ },
+ 'Essen & Fürsorge': {
+ learningGoals: [
+ 'Fürsorgliche Fragen rund ums Essen verstehen.',
+ 'Einladungen zum Essen passend beantworten.',
+ 'Kurze Essens-Dialoge laut üben.'
+ ],
+ corePatterns: ['Nikaon na ka?', 'Kaon ta.', 'Gusto ka mokaon?', 'Lami kaayo.'],
+ grammarFocus: [
+ { title: 'na als Zustandsmarker', text: '"na" markiert oft etwas, das bereits eingetreten ist oder jetzt gilt.', example: 'Nikaon na ka?' }
+ ],
+ speakingPrompts: [
+ { title: 'Fürsorge-Dialog', prompt: 'Frage, ob jemand schon gegessen hat, und biete Essen oder Wasser an.', cue: 'Nikaon na ka? Gusto ka mokaon?' }
+ ],
+ practicalTasks: [{ title: 'Rollenspiel', text: 'Spiele ein kurzes Gespräch zwischen Gastgeber und Gast beim Essen.' }]
+ },
+ 'Zeitformen - Grundlagen': {
+ learningGoals: [
+ 'Ni- und Mo- als einfache Zeitmarker unterscheiden.',
+ 'Kurze Sätze in Vergangenheit und Zukunft bilden.',
+ 'Das Muster laut mit mehreren Verben wiederholen.'
+ ],
+ corePatterns: ['Ni-kaon ko.', 'Mo-kaon ko.', 'Ni-adto ko.', 'Mo-adto ko.'],
+ grammarFocus: [
+ { title: 'Zeitpräfixe', text: 'Ni- verweist auf Vergangenes, Mo- auf Zukünftiges oder Bevorstehendes.', example: 'Ni-kaon ko. / Mo-kaon ko.' }
+ ],
+ speakingPrompts: [
+ { title: 'Vorher und nachher', prompt: 'Sage einen Satz über etwas, das du getan hast, und einen Satz über etwas, das du tun wirst.', cue: 'Ni-kaon ko. Mo-adto ko.' }
+ ],
+ practicalTasks: [{ title: 'Mustertraining', text: 'Nimm ein Verb und sprich es einmal mit Ni- und einmal mit Mo-.' }]
+ },
+ 'Woche 1 - Wiederholung': {
+ learningGoals: [
+ 'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
+ 'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
+ 'Eine kurze Alltagssequenz frei sprechen.'
+ ],
+ corePatterns: ['Kumusta ka?', 'Palangga taka.', 'Nikaon na ka?', 'Wala ko kasabot.'],
+ speakingPrompts: [
+ { title: 'Freie Wiederholung', prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.', cue: 'Kumusta ka? Palangga taka. Nikaon na ka?' }
+ ]
+ },
+ 'Woche 1 - Vokabeltest': {
+ learningGoals: [
+ 'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
+ 'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
+ 'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
+ ],
+ corePatterns: ['Kumusta', 'Salamat', 'Lami', 'Mingaw ko nimo']
+ }
+};
+
+async function resetBisayaProgress(courseIds) {
+ if (courseIds.length === 0) return { lessonProgress: 0, exerciseProgress: 0 };
+
+ const lessonIds = await sequelize.query(
+ `SELECT id FROM community.vocab_course_lesson WHERE course_id = ANY(:courseIds)`,
+ {
+ replacements: { courseIds },
+ type: sequelize.QueryTypes.SELECT
+ }
+ );
+
+ const exerciseIds = await sequelize.query(
+ `SELECT id FROM community.vocab_grammar_exercise WHERE lesson_id = ANY(:lessonIds)`,
+ {
+ replacements: { lessonIds: lessonIds.map((row) => row.id) || [0] },
+ type: sequelize.QueryTypes.SELECT
+ }
+ );
+
+ const deletedLessonProgress = await VocabCourseProgress.destroy({
+ where: { courseId: { [Op.in]: courseIds } }
+ });
+
+ let deletedExerciseProgress = 0;
+ if (exerciseIds.length > 0) {
+ deletedExerciseProgress = await VocabGrammarExerciseProgress.destroy({
+ where: { exerciseId: { [Op.in]: exerciseIds.map((row) => row.id) } }
+ });
+ }
+
+ return {
+ lessonProgress: deletedLessonProgress,
+ exerciseProgress: deletedExerciseProgress
+ };
+}
+
+async function applyBisayaCourseRefresh() {
+ await sequelize.authenticate();
+
+ const [bisayaLanguage] = await sequelize.query(
+ `SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
+ { type: sequelize.QueryTypes.SELECT }
+ );
+
+ if (!bisayaLanguage) {
+ console.error('❌ Bisaya-Sprache nicht gefunden.');
+ return;
+ }
+
+ const courses = await sequelize.query(
+ `SELECT id, title FROM community.vocab_course WHERE language_id = :languageId ORDER BY id ASC`,
+ {
+ replacements: { languageId: bisayaLanguage.id },
+ type: sequelize.QueryTypes.SELECT
+ }
+ );
+
+ const courseIds = courses.map((course) => course.id);
+ const resetStats = await resetBisayaProgress(courseIds);
+
+ let updatedLessons = 0;
+ for (const course of courses) {
+ const lessons = await VocabCourseLesson.findAll({
+ where: { courseId: course.id }
+ });
+
+ for (const lesson of lessons) {
+ const didactics = LESSON_DIDACTICS[lesson.title];
+ if (!didactics) continue;
+
+ await lesson.update({
+ learningGoals: didactics.learningGoals || [],
+ corePatterns: didactics.corePatterns || [],
+ grammarFocus: didactics.grammarFocus || [],
+ speakingPrompts: didactics.speakingPrompts || [],
+ practicalTasks: didactics.practicalTasks || []
+ });
+ updatedLessons++;
+ }
+ }
+
+ console.log('✅ Bisaya-Kursupdate vorbereitet.');
+ console.log(` Kurse: ${courses.length}`);
+ console.log(` Didaktisch aktualisierte Lektionen: ${updatedLessons}`);
+ console.log(` Gelöschte Lektionsfortschritte: ${resetStats.lessonProgress}`);
+ console.log(` Gelöschte Übungsfortschritte: ${resetStats.exerciseProgress}`);
+ console.log('');
+ console.log('Nächste Schritte:');
+ console.log('1. create-bisaya-course-content.js ausführen, um die neuen Übungen einzuspielen');
+ console.log('2. optional update-week1-bisaya-exercises.js ausführen, falls Woche 1 separat gepflegt wird');
+}
+
+applyBisayaCourseRefresh()
+ .then(() => {
+ sequelize.close();
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('❌ Fehler:', error);
+ sequelize.close();
+ process.exit(1);
+ });
diff --git a/backend/scripts/create-bisaya-course-content.js b/backend/scripts/create-bisaya-course-content.js
index 5b5bbfe..df5ac9c 100644
--- a/backend/scripts/create-bisaya-course-content.js
+++ b/backend/scripts/create-bisaya-course-content.js
@@ -14,6 +14,13 @@ import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js'
import VocabCourse from '../models/community/vocab_course.js';
import User from '../models/community/user.js';
+function withTypeName(exerciseTypeName, exercise) {
+ return {
+ ...exercise,
+ exerciseTypeName
+ };
+}
+
// Bisaya-spezifische Übungen basierend auf Lektionsthemen
const BISAYA_EXERCISES = {
// Lektion 1: Begrüßungen & Höflichkeit
@@ -62,6 +69,35 @@ const BISAYA_EXERCISES = {
correctAnswer: 0
},
explanation: '"Salamat" bedeutet "Danke" auf Bisaya.'
+ },
+ withTypeName('dialog_completion', {
+ title: 'Begrüßungsdialog ergänzen',
+ instruction: 'Ergänze die passende Antwort im Mini-Dialog.',
+ questionData: {
+ type: 'dialog_completion',
+ question: 'Welche Antwort passt auf die Begrüßung?',
+ dialog: ['A: Kumusta ka?', 'B: ...']
+ },
+ answerData: {
+ modelAnswer: 'Maayo ko, salamat.',
+ correct: ['Maayo ko, salamat.', 'Maayo ko. Salamat.']
+ },
+ explanation: 'Eine typische kurze Antwort ist "Maayo ko, salamat."'
+ }),
+ {
+ exerciseTypeId: 8,
+ title: 'Begrüßung frei sprechen',
+ instruction: 'Sprich eine kurze Begrüßung mit Frage und Antwort frei nach.',
+ questionData: {
+ type: 'speaking_from_memory',
+ question: 'Begrüße eine Person und antworte kurz auf "Kumusta ka?".',
+ expectedText: 'Kumusta ka? Maayo ko, salamat.',
+ keywords: ['kumusta', 'maayo', 'salamat']
+ },
+ answerData: {
+ type: 'speaking_from_memory'
+ },
+ explanation: 'Wichtig sind hier die Schlüsselwörter für Begrüßung, Antwort und Höflichkeit.'
}
],
@@ -188,7 +224,92 @@ const BISAYA_EXERCISES = {
alternatives: ['Mama', 'Nanay', 'Inahan']
},
explanation: '"Nanay" oder "Mama" bedeutet "Mutter" auf Bisaya.'
- }
+ },
+ {
+ exerciseTypeId: 3,
+ title: 'Familiensatz bauen',
+ instruction: 'Bilde aus den Wörtern einen kurzen Satz.',
+ questionData: {
+ type: 'sentence_building',
+ question: 'Baue einen Satz: "Das ist meine Mutter."',
+ tokens: ['Si', 'Nanay', 'nako', 'ni']
+ },
+ answerData: {
+ correct: ['Si Nanay nako ni.', 'Si Nanay ni nako.']
+ },
+ explanation: 'Mit "Si Nanay nako ni." stellst du deine Mutter kurz vor.'
+ },
+ withTypeName('situational_response', {
+ title: 'Familie vorstellen',
+ instruction: 'Antworte kurz auf die Situation.',
+ questionData: {
+ type: 'situational_response',
+ question: 'Jemand fragt dich nach deiner Familie. Stelle kurz Mutter und älteren Bruder vor.',
+ keywords: ['nanay', 'kuya']
+ },
+ answerData: {
+ modelAnswer: 'Si Nanay ug si Kuya.',
+ keywords: ['nanay', 'kuya']
+ },
+ explanation: 'Für diese Aufgabe reichen kurze, klare Familiennennungen.'
+ })
+ ],
+
+ 'Essen & Fürsorge': [
+ {
+ exerciseTypeId: 2,
+ title: 'Fürsorgefrage verstehen',
+ instruction: 'Wähle die richtige Bedeutung.',
+ questionData: {
+ type: 'multiple_choice',
+ question: 'Was bedeutet "Nikaon na ka?"?',
+ options: ['Hast du schon gegessen?', 'Bist du müde?', 'Kommst du nach Hause?', 'Möchtest du Wasser?']
+ },
+ answerData: { type: 'multiple_choice', correctAnswer: 0 },
+ explanation: '"Nikaon na ka?" ist eine sehr fürsorgliche Alltagsfrage.'
+ },
+ {
+ exerciseTypeId: 1,
+ title: 'Essensdialog ergänzen',
+ instruction: 'Fülle die Lücken mit den passenden Wörtern.',
+ questionData: {
+ type: 'gap_fill',
+ text: 'Nikaon {gap} ka? {gap} ta!',
+ gaps: 2
+ },
+ answerData: {
+ answers: ['na', 'Kaon']
+ },
+ explanation: '"na" markiert hier den bereits eingetretenen Zustand; "Kaon ta!" heißt "Lass uns essen!".'
+ },
+ withTypeName('dialog_completion', {
+ title: 'Einladung zum Essen ergänzen',
+ instruction: 'Ergänze die passende Antwort.',
+ questionData: {
+ type: 'dialog_completion',
+ question: 'Welche Antwort passt auf die Einladung?',
+ dialog: ['A: Kaon ta!', 'B: ...']
+ },
+ answerData: {
+ modelAnswer: 'Oo, gusto ko.',
+ correct: ['Oo, gusto ko.', 'Oo, mokaon ko.']
+ },
+ explanation: 'Eine natürliche kurze Reaktion ist "Oo, gusto ko."'
+ }),
+ withTypeName('situational_response', {
+ title: 'Fürsorglich reagieren',
+ instruction: 'Reagiere passend auf die Situation.',
+ questionData: {
+ type: 'situational_response',
+ question: 'Jemand sieht hungrig aus. Frage fürsorglich nach und biete Essen an.',
+ keywords: ['nikaon', 'kaon']
+ },
+ answerData: {
+ modelAnswer: 'Nikaon na ka? Kaon ta.',
+ keywords: ['nikaon', 'kaon']
+ },
+ explanation: 'Die Übung trainiert einen sehr typischen fürsorglichen Mini-Dialog.'
+ })
],
// Lektion: Haus & Familie (Balay, Kwarto, Kusina, Pamilya)
@@ -424,6 +545,34 @@ const BISAYA_EXERCISES = {
answers: ['Ni', 'Mo']
},
explanation: 'Ni- für Vergangenheit, Mo- für Zukunft.'
+ },
+ withTypeName('pattern_drill', {
+ title: 'Zeitmuster anwenden',
+ instruction: 'Bilde mit demselben Muster einen Zukunftssatz.',
+ questionData: {
+ type: 'pattern_drill',
+ question: 'Verwende das Muster für "gehen".',
+ pattern: 'Mo- + Verb + ko'
+ },
+ answerData: {
+ modelAnswer: 'Mo-adto ko.',
+ correct: ['Mo-adto ko.', 'Moadto ko.']
+ },
+ explanation: 'Mit "Mo-" kannst du ein einfaches Zukunftsmuster bilden.'
+ }),
+ {
+ exerciseTypeId: 3,
+ title: 'Vergangenheit und Zukunft bauen',
+ instruction: 'Schreibe beide Formen nacheinander auf.',
+ questionData: {
+ type: 'sentence_building',
+ question: 'Formuliere: "Ich habe gegessen. Ich werde essen."',
+ tokens: ['Ni-kaon', 'ko', 'Mo-kaon', 'ko']
+ },
+ answerData: {
+ correct: ['Ni-kaon ko. Mo-kaon ko.', 'Nikaon ko. Mokaon ko.']
+ },
+ explanation: 'Die Übung trainiert den direkten Wechsel zwischen den beiden Zeitmarkern.'
}
],
@@ -1103,7 +1252,35 @@ const BISAYA_EXERCISES = {
},
answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".'
- }
+ },
+ {
+ exerciseTypeId: 3,
+ title: 'Woche 1: Minisatz bauen',
+ instruction: 'Schreibe eine kurze Sequenz aus Begrüßung und Fürsorge.',
+ questionData: {
+ type: 'sentence_building',
+ question: 'Baue: "Wie geht es dir? Hast du schon gegessen?"',
+ tokens: ['Kumusta', 'ka', 'Nikaon', 'na', 'ka']
+ },
+ answerData: {
+ correct: ['Kumusta ka? Nikaon na ka?', 'Kumusta ka. Nikaon na ka?']
+ },
+ explanation: 'Hier kombinierst du zwei wichtige Muster aus Woche 1.'
+ },
+ withTypeName('dialog_completion', {
+ title: 'Woche 1: Dialog ergänzen',
+ instruction: 'Ergänze die passende liebevolle Reaktion.',
+ questionData: {
+ type: 'dialog_completion',
+ question: 'Welche Antwort passt?',
+ dialog: ['A: Mingaw ko nimo.', 'B: ...']
+ },
+ answerData: {
+ modelAnswer: 'Palangga taka.',
+ correct: ['Palangga taka.']
+ },
+ explanation: 'Die Kombination klingt im Familienkontext warm und natürlich.'
+ })
],
// Woche 1 - Vokabeltest (Lektion 10)
@@ -1167,10 +1344,48 @@ const BISAYA_EXERCISES = {
},
answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".'
- }
+ },
+ withTypeName('situational_response', {
+ title: 'Woche 1: Situative Kurzantwort',
+ instruction: 'Reagiere passend auf die Situation.',
+ questionData: {
+ type: 'situational_response',
+ question: 'Jemand fragt: "Kumusta ka?" Antworte kurz und höflich.',
+ keywords: ['maayo', 'salamat']
+ },
+ answerData: {
+ modelAnswer: 'Maayo ko, salamat.',
+ keywords: ['maayo', 'salamat']
+ },
+ explanation: 'Eine kurze höfliche Antwort reicht hier völlig aus.'
+ })
]
};
+async function resolveExerciseTypeId(exercise) {
+ if (exercise.exerciseTypeId) {
+ return exercise.exerciseTypeId;
+ }
+
+ if (!exercise.exerciseTypeName) {
+ throw new Error(`Kein exerciseTypeId oder exerciseTypeName für "${exercise.title}" definiert`);
+ }
+
+ const [type] = await sequelize.query(
+ `SELECT id FROM community.vocab_grammar_exercise_type WHERE name = :name LIMIT 1`,
+ {
+ replacements: { name: exercise.exerciseTypeName },
+ type: sequelize.QueryTypes.SELECT
+ }
+ );
+
+ if (!type) {
+ throw new Error(`Übungstyp "${exercise.exerciseTypeName}" nicht gefunden`);
+ }
+
+ return Number(type.id);
+}
+
async function findOrCreateSystemUser() {
let systemUser = await User.findOne({
where: {
@@ -1270,10 +1485,14 @@ async function createBisayaCourseContent() {
const replacePlaceholders = [
'Woche 1 - Wiederholung',
'Woche 1 - Vokabeltest',
+ 'Begrüßungen & Höflichkeit',
+ 'Familienwörter',
+ 'Essen & Fürsorge',
'Alltagsgespräche - Teil 1',
'Alltagsgespräche - Teil 2',
'Haus & Familie',
- 'Ort & Richtung'
+ 'Ort & Richtung',
+ 'Zeitformen - Grundlagen'
].includes(lesson.title);
const existingCount = await VocabGrammarExercise.count({
where: { lessonId: lesson.id }
@@ -1292,9 +1511,10 @@ async function createBisayaCourseContent() {
// Erstelle Übungen
let exerciseNumber = 1;
for (const exerciseData of exercises) {
+ const exerciseTypeId = await resolveExerciseTypeId(exerciseData);
await VocabGrammarExercise.create({
lessonId: lesson.id,
- exerciseTypeId: exerciseData.exerciseTypeId,
+ exerciseTypeId,
exerciseNumber: exerciseNumber++,
title: exerciseData.title,
instruction: exerciseData.instruction,
diff --git a/backend/scripts/create-bisaya-course.js b/backend/scripts/create-bisaya-course.js
index 90be8ad..19f7fa8 100755
--- a/backend/scripts/create-bisaya-course.js
+++ b/backend/scripts/create-bisaya-course.js
@@ -12,6 +12,174 @@ import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import User from '../models/community/user.js';
import crypto from 'crypto';
+const LESSON_DIDACTICS = {
+ 'Begrüßungen & Höflichkeit': {
+ learningGoals: [
+ 'Einfache Begrüßungen verstehen und selbst verwenden.',
+ 'Höfliche Reaktionen wie Danke und Bitte passend einsetzen.',
+ 'Ein kurzes Begrüßungs-Mini-Gespräch laut üben.'
+ ],
+ corePatterns: [
+ 'Kumusta ka?',
+ 'Maayo ko.',
+ 'Salamat.',
+ 'Palihug.'
+ ],
+ grammarFocus: [
+ {
+ title: 'Kurzantworten mit ko',
+ text: 'Mit "ko" sprichst du über dich selbst: "Maayo ko."',
+ example: 'Maayo ko. = Mir geht es gut.'
+ }
+ ],
+ speakingPrompts: [
+ {
+ title: 'Mini-Gespräch',
+ prompt: 'Begrüße eine Person, frage nach dem Befinden und reagiere höflich.',
+ cue: 'Kumusta ka? Maayo ko. Salamat.'
+ }
+ ],
+ practicalTasks: [
+ {
+ title: 'Alltag',
+ text: 'Sprich die Begrüßung dreimal laut und variiere die Antwort.'
+ }
+ ]
+ },
+ 'Familienwörter': {
+ learningGoals: [
+ 'Die wichtigsten Familienbezeichnungen sicher erkennen.',
+ 'Familienmitglieder mit respektvollen Wörtern ansprechen.',
+ 'Kurze Sätze über die eigene Familie bilden.'
+ ],
+ corePatterns: [
+ 'Si Nanay',
+ 'Si Tatay',
+ 'Kuya nako',
+ 'Ate nako'
+ ],
+ grammarFocus: [
+ {
+ title: 'Respekt in Familienanreden',
+ text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.',
+ example: 'Kuya, palihug.'
+ }
+ ],
+ speakingPrompts: [
+ {
+ title: 'Meine Familie',
+ prompt: 'Stelle zwei Familienmitglieder mit einem kurzen Satz vor.',
+ cue: 'Si Nanay. Si Kuya.'
+ }
+ ],
+ practicalTasks: [
+ {
+ title: 'Familienpraxis',
+ text: 'Nenne laut fünf Familienwörter und bilde danach zwei Mini-Sätze.'
+ }
+ ]
+ },
+ 'Essen & Fürsorge': {
+ learningGoals: [
+ 'Fürsorgliche Fragen rund ums Essen verstehen.',
+ 'Einladungen zum Essen passend beantworten.',
+ 'Kurze Essens-Dialoge laut üben.'
+ ],
+ corePatterns: [
+ 'Nikaon na ka?',
+ 'Kaon ta.',
+ 'Gusto ka mokaon?',
+ 'Lami kaayo.'
+ ],
+ grammarFocus: [
+ {
+ title: 'na als Zustandsmarker',
+ text: '"na" markiert oft etwas, das bereits eingetreten ist oder jetzt gilt.',
+ example: 'Nikaon na ka?'
+ }
+ ],
+ speakingPrompts: [
+ {
+ title: 'Fürsorge-Dialog',
+ prompt: 'Frage, ob jemand schon gegessen hat, und biete Essen oder Wasser an.',
+ cue: 'Nikaon na ka? Gusto ka mokaon?'
+ }
+ ],
+ practicalTasks: [
+ {
+ title: 'Rollenspiel',
+ text: 'Spiele ein kurzes Gespräch zwischen Gastgeber und Gast beim Essen.'
+ }
+ ]
+ },
+ 'Zeitformen - Grundlagen': {
+ learningGoals: [
+ 'Ni- und Mo- als einfache Zeitmarker unterscheiden.',
+ 'Kurze Sätze in Vergangenheit und Zukunft bilden.',
+ 'Das Muster laut mit mehreren Verben wiederholen.'
+ ],
+ corePatterns: [
+ 'Ni-kaon ko.',
+ 'Mo-kaon ko.',
+ 'Ni-adto ko.',
+ 'Mo-adto ko.'
+ ],
+ grammarFocus: [
+ {
+ title: 'Zeitpräfixe',
+ text: 'Ni- verweist auf Vergangenes, Mo- auf Zukünftiges oder Bevorstehendes.',
+ example: 'Ni-kaon ko. / Mo-kaon ko.'
+ }
+ ],
+ speakingPrompts: [
+ {
+ title: 'Vorher und nachher',
+ prompt: 'Sage einen Satz über etwas, das du getan hast, und einen Satz über etwas, das du tun wirst.',
+ cue: 'Ni-kaon ko. Mo-adto ko.'
+ }
+ ],
+ practicalTasks: [
+ {
+ title: 'Mustertraining',
+ text: 'Nimm ein Verb und sprich es einmal mit Ni- und einmal mit Mo-.'
+ }
+ ]
+ },
+ 'Woche 1 - Wiederholung': {
+ learningGoals: [
+ 'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
+ 'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
+ 'Eine kurze Alltagssequenz frei sprechen.'
+ ],
+ corePatterns: [
+ 'Kumusta ka?',
+ 'Palangga taka.',
+ 'Nikaon na ka?',
+ 'Wala ko kasabot.'
+ ],
+ speakingPrompts: [
+ {
+ title: 'Freie Wiederholung',
+ prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.',
+ cue: 'Kumusta ka? Palangga taka. Nikaon na ka?'
+ }
+ ]
+ },
+ 'Woche 1 - Vokabeltest': {
+ learningGoals: [
+ 'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
+ 'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
+ 'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
+ ],
+ corePatterns: [
+ 'Kumusta',
+ 'Salamat',
+ 'Lami',
+ 'Mingaw ko nimo'
+ ]
+ }
+};
+
const LESSONS = [
// WOCHE 1: Grundlagen & Aussprache
{ week: 1, day: 1, num: 1, type: 'conversation', title: 'Begrüßungen & Höflichkeit',
@@ -262,6 +430,11 @@ async function createBisayaCourse(languageId, ownerHashedId) {
dayNumber: lessonData.day,
lessonType: lessonData.type,
culturalNotes: lessonData.cultural,
+ learningGoals: LESSON_DIDACTICS[lessonData.title]?.learningGoals || [],
+ corePatterns: LESSON_DIDACTICS[lessonData.title]?.corePatterns || [],
+ grammarFocus: LESSON_DIDACTICS[lessonData.title]?.grammarFocus || [],
+ speakingPrompts: LESSON_DIDACTICS[lessonData.title]?.speakingPrompts || [],
+ practicalTasks: LESSON_DIDACTICS[lessonData.title]?.practicalTasks || [],
targetMinutes: lessonData.targetMin,
targetScorePercent: lessonData.targetScore,
requiresReview: lessonData.review
diff --git a/backend/scripts/update-bisaya-didactics.js b/backend/scripts/update-bisaya-didactics.js
new file mode 100644
index 0000000..116e594
--- /dev/null
+++ b/backend/scripts/update-bisaya-didactics.js
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+/**
+ * Pflegt didaktische Felder in bestehenden Bisaya-Kursen nach.
+ *
+ * Verwendung:
+ * node backend/scripts/update-bisaya-didactics.js
+ */
+
+import { sequelize } from '../utils/sequelize.js';
+import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
+
+const LESSON_DIDACTICS = {
+ 'Begrüßungen & Höflichkeit': {
+ learningGoals: [
+ 'Einfache Begrüßungen verstehen und selbst verwenden.',
+ 'Höfliche Reaktionen wie Danke und Bitte passend einsetzen.',
+ 'Ein kurzes Begrüßungs-Mini-Gespräch laut üben.'
+ ],
+ corePatterns: ['Kumusta ka?', 'Maayo ko.', 'Salamat.', 'Palihug.'],
+ grammarFocus: [
+ { title: 'Kurzantworten mit ko', text: 'Mit "ko" sprichst du über dich selbst: "Maayo ko."', example: 'Maayo ko. = Mir geht es gut.' }
+ ],
+ speakingPrompts: [
+ { title: 'Mini-Gespräch', prompt: 'Begrüße eine Person, frage nach dem Befinden und reagiere höflich.', cue: 'Kumusta ka? Maayo ko. Salamat.' }
+ ],
+ practicalTasks: [
+ { title: 'Alltag', text: 'Sprich die Begrüßung dreimal laut und variiere die Antwort.' }
+ ]
+ },
+ 'Familienwörter': {
+ learningGoals: [
+ 'Die wichtigsten Familienbezeichnungen sicher erkennen.',
+ 'Familienmitglieder mit respektvollen Wörtern ansprechen.',
+ 'Kurze Sätze über die eigene Familie bilden.'
+ ],
+ corePatterns: ['Si Nanay', 'Si Tatay', 'Kuya nako', 'Ate nako'],
+ grammarFocus: [
+ { title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' }
+ ],
+ speakingPrompts: [
+ { title: 'Meine Familie', prompt: 'Stelle zwei Familienmitglieder mit einem kurzen Satz vor.', cue: 'Si Nanay. Si Kuya.' }
+ ],
+ practicalTasks: [
+ { title: 'Familienpraxis', text: 'Nenne laut fünf Familienwörter und bilde danach zwei Mini-Sätze.' }
+ ]
+ },
+ 'Essen & Fürsorge': {
+ learningGoals: [
+ 'Fürsorgliche Fragen rund ums Essen verstehen.',
+ 'Einladungen zum Essen passend beantworten.',
+ 'Kurze Essens-Dialoge laut üben.'
+ ],
+ corePatterns: ['Nikaon na ka?', 'Kaon ta.', 'Gusto ka mokaon?', 'Lami kaayo.'],
+ grammarFocus: [
+ { title: 'na als Zustandsmarker', text: '"na" markiert oft etwas, das bereits eingetreten ist oder jetzt gilt.', example: 'Nikaon na ka?' }
+ ],
+ speakingPrompts: [
+ { title: 'Fürsorge-Dialog', prompt: 'Frage, ob jemand schon gegessen hat, und biete Essen oder Wasser an.', cue: 'Nikaon na ka? Gusto ka mokaon?' }
+ ],
+ practicalTasks: [
+ { title: 'Rollenspiel', text: 'Spiele ein kurzes Gespräch zwischen Gastgeber und Gast beim Essen.' }
+ ]
+ },
+ 'Zeitformen - Grundlagen': {
+ learningGoals: [
+ 'Ni- und Mo- als einfache Zeitmarker unterscheiden.',
+ 'Kurze Sätze in Vergangenheit und Zukunft bilden.',
+ 'Das Muster laut mit mehreren Verben wiederholen.'
+ ],
+ corePatterns: ['Ni-kaon ko.', 'Mo-kaon ko.', 'Ni-adto ko.', 'Mo-adto ko.'],
+ grammarFocus: [
+ { title: 'Zeitpräfixe', text: 'Ni- verweist auf Vergangenes, Mo- auf Zukünftiges oder Bevorstehendes.', example: 'Ni-kaon ko. / Mo-kaon ko.' }
+ ],
+ speakingPrompts: [
+ { title: 'Vorher und nachher', prompt: 'Sage einen Satz über etwas, das du getan hast, und einen Satz über etwas, das du tun wirst.', cue: 'Ni-kaon ko. Mo-adto ko.' }
+ ],
+ practicalTasks: [
+ { title: 'Mustertraining', text: 'Nimm ein Verb und sprich es einmal mit Ni- und einmal mit Mo-.' }
+ ]
+ },
+ 'Woche 1 - Wiederholung': {
+ learningGoals: [
+ 'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
+ 'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
+ 'Eine kurze Alltagssequenz frei sprechen.'
+ ],
+ corePatterns: ['Kumusta ka?', 'Palangga taka.', 'Nikaon na ka?', 'Wala ko kasabot.'],
+ speakingPrompts: [
+ { title: 'Freie Wiederholung', prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.', cue: 'Kumusta ka? Palangga taka. Nikaon na ka?' }
+ ]
+ },
+ 'Woche 1 - Vokabeltest': {
+ learningGoals: [
+ 'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
+ 'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
+ 'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
+ ],
+ corePatterns: ['Kumusta', 'Salamat', 'Lami', 'Mingaw ko nimo']
+ }
+};
+
+async function updateBisayaDidactics() {
+ await sequelize.authenticate();
+ const [bisayaLanguage] = await sequelize.query(
+ `SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
+ { type: sequelize.QueryTypes.SELECT }
+ );
+
+ if (!bisayaLanguage) {
+ console.error('❌ Bisaya-Sprache nicht gefunden.');
+ return;
+ }
+
+ const lessons = await sequelize.query(
+ `SELECT l.id
+ FROM community.vocab_course_lesson l
+ JOIN community.vocab_course c ON c.id = l.course_id
+ WHERE c.language_id = :languageId`,
+ {
+ replacements: { languageId: bisayaLanguage.id },
+ type: sequelize.QueryTypes.SELECT
+ }
+ );
+
+ let updated = 0;
+ for (const row of lessons) {
+ const lesson = await VocabCourseLesson.findByPk(row.id);
+ const didactics = LESSON_DIDACTICS[lesson.title];
+ if (!didactics) continue;
+
+ await lesson.update({
+ learningGoals: didactics.learningGoals || [],
+ corePatterns: didactics.corePatterns || [],
+ grammarFocus: didactics.grammarFocus || [],
+ speakingPrompts: didactics.speakingPrompts || [],
+ practicalTasks: didactics.practicalTasks || []
+ });
+ updated++;
+ console.log(`✅ Didaktik aktualisiert: Lektion ${lesson.lessonNumber} - ${lesson.title}`);
+ }
+
+ console.log(`\n🎉 Fertig. ${updated} Lektion(en) aktualisiert.`);
+}
+
+updateBisayaDidactics()
+ .then(() => {
+ sequelize.close();
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('❌ Fehler:', error);
+ sequelize.close();
+ process.exit(1);
+ });
diff --git a/backend/scripts/update-week1-bisaya-exercises.js b/backend/scripts/update-week1-bisaya-exercises.js
index a5116e5..4448cdb 100644
--- a/backend/scripts/update-week1-bisaya-exercises.js
+++ b/backend/scripts/update-week1-bisaya-exercises.js
@@ -14,6 +14,13 @@ import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import User from '../models/community/user.js';
+function withTypeName(exerciseTypeName, exercise) {
+ return {
+ ...exercise,
+ exerciseTypeName
+ };
+}
+
const LESSON_TITLES = ['Woche 1 - Wiederholung', 'Woche 1 - Vokabeltest'];
const BISAYA_EXERCISES = {
@@ -22,17 +29,40 @@ const BISAYA_EXERCISES = {
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Mutter" auf Bisaya?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Mutter" auf Bisaya?', options: ['Nanay', 'Tatay', 'Kuya', 'Ate'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Nanay" bedeutet "Mutter" auf Bisaya.' },
{ exerciseTypeId: 2, title: 'Wiederholung: Was bedeutet "Palangga taka"?', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Palangga taka"?', options: ['Ich hab dich lieb', 'Danke', 'Guten Tag', 'Auf Wiedersehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Palangga taka" bedeutet "Ich hab dich lieb" - wärmer als "I love you" im Familienkontext.' },
{ exerciseTypeId: 2, title: 'Wiederholung: Was fragt man mit "Nikaon ka?"?', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Nikaon ka?"?', options: ['Hast du schon gegessen?', 'Wie geht es dir?', 'Danke', 'Bitte'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Nikaon ka?" bedeutet "Hast du schon gegessen?" - typisch fürsorglich auf den Philippinen.' },
- { exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Ich verstehe nicht"?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?', options: ['Wala ko kasabot', 'Salamat', 'Maayo', 'Palihug'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".' }
+ { exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Ich verstehe nicht"?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?', options: ['Wala ko kasabot', 'Salamat', 'Maayo', 'Palihug'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".' },
+ { exerciseTypeId: 3, title: 'Woche 1: Minisatz bauen', instruction: 'Schreibe eine kurze Sequenz aus Begrüßung und Fürsorge.', questionData: { type: 'sentence_building', question: 'Baue: "Wie geht es dir? Hast du schon gegessen?"', tokens: ['Kumusta', 'ka', 'Nikaon', 'na', 'ka'] }, answerData: { correct: ['Kumusta ka? Nikaon na ka?', 'Kumusta ka. Nikaon na ka?'] }, explanation: 'Hier kombinierst du zwei wichtige Muster aus Woche 1.' },
+ withTypeName('dialog_completion', { title: 'Woche 1: Dialog ergänzen', instruction: 'Ergänze die passende liebevolle Reaktion.', questionData: { type: 'dialog_completion', question: 'Welche Antwort passt?', dialog: ['A: Mingaw ko nimo.', 'B: ...'] }, answerData: { modelAnswer: 'Palangga taka.', correct: ['Palangga taka.'] }, explanation: 'Die Kombination klingt im Familienkontext warm und natürlich.' })
],
'Woche 1 - Vokabeltest': [
{ exerciseTypeId: 2, title: 'Vokabeltest: Kumusta', instruction: 'Was bedeutet "Kumusta"?', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Kumusta"?', options: ['Wie geht es dir?', 'Danke', 'Bitte', 'Auf Wiedersehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Kumusta" kommt von spanisch "¿Cómo está?" - "Wie geht es dir?"' },
{ exerciseTypeId: 2, title: 'Vokabeltest: Lola', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Lola"?', options: ['Großmutter', 'Großvater', 'Mutter', 'Vater'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Lola" = Großmutter, "Lolo" = Großvater.' },
{ exerciseTypeId: 2, title: 'Vokabeltest: Salamat', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Salamat"?', options: ['Danke', 'Bitte', 'Entschuldigung', 'Gern geschehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Salamat" bedeutet "Danke".' },
{ exerciseTypeId: 2, title: 'Vokabeltest: Lami', instruction: 'Was bedeutet "Lami"?', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Lami"?', options: ['Lecker', 'Viel', 'Gut', 'Schnell'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Lami" bedeutet "lecker" oder "schmackhaft" - wichtig beim Essen!' },
- { exerciseTypeId: 2, title: 'Vokabeltest: Mingaw ko nimo', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Mingaw ko nimo"?', options: ['Ich vermisse dich', 'Ich freue mich', 'Ich mag dich', 'Ich liebe dich'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".' }
+ { exerciseTypeId: 2, title: 'Vokabeltest: Mingaw ko nimo', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Mingaw ko nimo"?', options: ['Ich vermisse dich', 'Ich freue mich', 'Ich mag dich', 'Ich liebe dich'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".' },
+ withTypeName('situational_response', { title: 'Woche 1: Situative Kurzantwort', instruction: 'Reagiere passend auf die Situation.', questionData: { type: 'situational_response', question: 'Jemand fragt: "Kumusta ka?" Antworte kurz und höflich.', keywords: ['maayo', 'salamat'] }, answerData: { modelAnswer: 'Maayo ko, salamat.', keywords: ['maayo', 'salamat'] }, explanation: 'Eine kurze höfliche Antwort reicht hier völlig aus.' })
]
};
+async function resolveExerciseTypeId(exercise) {
+ if (exercise.exerciseTypeId) {
+ return exercise.exerciseTypeId;
+ }
+
+ const [type] = await sequelize.query(
+ `SELECT id FROM community.vocab_grammar_exercise_type WHERE name = :name LIMIT 1`,
+ {
+ replacements: { name: exercise.exerciseTypeName },
+ type: sequelize.QueryTypes.SELECT
+ }
+ );
+
+ if (!type) {
+ throw new Error(`Übungstyp "${exercise.exerciseTypeName}" nicht gefunden`);
+ }
+
+ return Number(type.id);
+}
+
async function updateWeek1BisayaExercises() {
await sequelize.authenticate();
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
@@ -93,9 +123,10 @@ async function updateWeek1BisayaExercises() {
let exerciseNumber = 1;
for (const ex of exercises) {
+ const exerciseTypeId = await resolveExerciseTypeId(ex);
await VocabGrammarExercise.create({
lessonId: lesson.id,
- exerciseTypeId: ex.exerciseTypeId,
+ exerciseTypeId,
exerciseNumber: exerciseNumber++,
title: ex.title,
instruction: ex.instruction,
diff --git a/backend/services/settingsService.js b/backend/services/settingsService.js
index 5e0b8ef..8c6ddab 100644
--- a/backend/services/settingsService.js
+++ b/backend/services/settingsService.js
@@ -10,7 +10,6 @@ import InterestTranslation from '../models/type/interest_translation.js';
import { Op } from 'sequelize';
import UserParamVisibilityType from '../models/type/user_param_visibility.js';
import UserParamVisibility from '../models/community/user_param_visibility.js';
-import { generateIv } from '../utils/encryption.js';
class SettingsService extends BaseService{
async getUserParams(userId, paramDescriptions) {
@@ -381,6 +380,129 @@ class SettingsService extends BaseService{
throw error;
}
}
+
+ /**
+ * LLM-/Sprachassistent: Werte in community.user_param, Typen in type.user_param,
+ * Gruppe type.settings.name = languageAssistant. API-Key separat (llm_api_key), Metadaten als JSON in llm_settings.
+ * Kein Klartext-Key an den Client.
+ */
+ async getLlmSettings(hashedUserId) {
+ const user = await this.getUserByHashedId(hashedUserId);
+ const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
+ const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
+ if (!settingsType || !apiKeyType) {
+ return {
+ enabled: true,
+ baseUrl: '',
+ model: 'gpt-4o-mini',
+ hasKey: false,
+ keyLast4: null
+ };
+ }
+
+ const settingsRow = await UserParam.findOne({
+ where: { userId: user.id, paramTypeId: settingsType.id }
+ });
+ const keyRow = await UserParam.findOne({
+ where: { userId: user.id, paramTypeId: apiKeyType.id }
+ });
+
+ let parsed = {};
+ if (settingsRow?.value) {
+ try {
+ parsed = JSON.parse(settingsRow.value);
+ } catch {
+ parsed = {};
+ }
+ }
+
+ const hasKey = Boolean(keyRow && keyRow.value && String(keyRow.value).trim());
+
+ return {
+ enabled: parsed.enabled !== false,
+ baseUrl: parsed.baseUrl || '',
+ model: parsed.model || 'gpt-4o-mini',
+ hasKey,
+ keyLast4: parsed.keyLast4 || null
+ };
+ }
+
+ async saveLlmSettings(hashedUserId, payload) {
+ const user = await this.getUserByHashedId(hashedUserId);
+ const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
+ const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
+ if (!settingsType || !apiKeyType) {
+ throw new Error(
+ 'LLM-Einstellungstypen fehlen (languageAssistant / llm_settings / llm_api_key). initializeSettings & initializeTypes ausführen.'
+ );
+ }
+
+ const settingsRow = await UserParam.findOne({
+ where: { userId: user.id, paramTypeId: settingsType.id }
+ });
+ let parsed = {};
+ if (settingsRow?.value) {
+ try {
+ parsed = JSON.parse(settingsRow.value);
+ } catch {
+ parsed = {};
+ }
+ }
+
+ const { apiKey, clearKey, baseUrl, model, enabled } = payload;
+
+ if (clearKey) {
+ const keyRow = await UserParam.findOne({
+ where: { userId: user.id, paramTypeId: apiKeyType.id }
+ });
+ if (keyRow) {
+ await keyRow.destroy();
+ }
+ delete parsed.keyLast4;
+ } else if (apiKey !== undefined && String(apiKey).trim() !== '') {
+ const plain = String(apiKey).trim();
+ parsed.keyLast4 = plain.length >= 4 ? plain.slice(-4) : plain;
+ const [keyRow, keyCreated] = await UserParam.findOrCreate({
+ where: { userId: user.id, paramTypeId: apiKeyType.id },
+ defaults: {
+ userId: user.id,
+ paramTypeId: apiKeyType.id,
+ value: plain
+ }
+ });
+ if (!keyCreated) {
+ await keyRow.update({ value: plain });
+ }
+ }
+
+ if (baseUrl !== undefined) {
+ parsed.baseUrl = String(baseUrl).trim();
+ }
+ if (model !== undefined) {
+ parsed.model = String(model).trim() || 'gpt-4o-mini';
+ }
+ if (enabled !== undefined) {
+ parsed.enabled = Boolean(enabled);
+ }
+ if (!parsed.model) {
+ parsed.model = 'gpt-4o-mini';
+ }
+
+ const jsonStr = JSON.stringify(parsed);
+ const [metaRow, metaCreated] = await UserParam.findOrCreate({
+ where: { userId: user.id, paramTypeId: settingsType.id },
+ defaults: {
+ userId: user.id,
+ paramTypeId: settingsType.id,
+ value: jsonStr
+ }
+ });
+ if (!metaCreated) {
+ await metaRow.update({ value: jsonStr });
+ }
+
+ return { success: true };
+ }
}
export default new SettingsService();
diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js
index 8f80fd9..bca31b0 100644
--- a/backend/services/vocabService.js
+++ b/backend/services/vocabService.js
@@ -29,6 +29,126 @@ export default class VocabService {
.replace(/\s+/g, ' ');
}
+ _normalizeTextAnswer(text) {
+ return String(text || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[.,!?;:¿¡"]/g, '')
+ .replace(/\s+/g, ' ');
+ }
+
+ _normalizeStringList(value) {
+ if (!value) return [];
+ if (Array.isArray(value)) {
+ return value
+ .map((entry) => String(entry || '').trim())
+ .filter(Boolean);
+ }
+ if (typeof value === 'string') {
+ return value
+ .split(/\r?\n|;/)
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ }
+ return [];
+ }
+
+ _normalizeStructuredList(value, keys = ['title', 'text']) {
+ if (!value) return [];
+ if (Array.isArray(value)) {
+ return value
+ .map((entry) => {
+ if (typeof entry === 'string') {
+ return { title: '', text: entry.trim() };
+ }
+ if (!entry || typeof entry !== 'object') return null;
+ const normalized = {};
+ keys.forEach((key) => {
+ if (entry[key] !== undefined && entry[key] !== null) {
+ normalized[key] = String(entry[key]).trim();
+ }
+ });
+ return Object.keys(normalized).length > 0 ? normalized : null;
+ })
+ .filter(Boolean);
+ }
+ return [];
+ }
+
+ _buildLessonDidactics(plainLesson) {
+ const grammarExercises = Array.isArray(plainLesson.grammarExercises) ? plainLesson.grammarExercises : [];
+ const grammarExplanations = [];
+ const patterns = [];
+ const speakingPrompts = [];
+
+ grammarExercises.forEach((exercise) => {
+ const questionData = typeof exercise.questionData === 'string'
+ ? JSON.parse(exercise.questionData)
+ : (exercise.questionData || {});
+
+ if (exercise.explanation) {
+ grammarExplanations.push({
+ title: exercise.title || '',
+ text: exercise.explanation
+ });
+ }
+
+ const patternCandidates = [
+ questionData.pattern,
+ questionData.exampleSentence,
+ questionData.modelAnswer,
+ questionData.promptSentence
+ ].filter(Boolean);
+
+ patternCandidates.forEach((candidate) => {
+ patterns.push(String(candidate).trim());
+ });
+
+ if (questionData.type === 'reading_aloud' || questionData.type === 'speaking_from_memory') {
+ speakingPrompts.push({
+ title: exercise.title || '',
+ prompt: questionData.question || questionData.text || '',
+ cue: questionData.expectedText || '',
+ keywords: Array.isArray(questionData.keywords) ? questionData.keywords : []
+ });
+ }
+ });
+
+ const uniqueGrammarExplanations = grammarExplanations.filter((item, index, list) => {
+ const signature = `${item.title}::${item.text}`;
+ return list.findIndex((entry) => `${entry.title}::${entry.text}` === signature) === index;
+ });
+
+ const uniquePatterns = [...new Set(patterns.map((item) => String(item || '').trim()).filter(Boolean))];
+
+ const learningGoals = this._normalizeStringList(plainLesson.learningGoals);
+ const corePatterns = this._normalizeStringList(plainLesson.corePatterns);
+ const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']);
+ const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']);
+ const practicalTasks = this._normalizeStructuredList(plainLesson.practicalTasks, ['title', 'text']);
+
+ return {
+ learningGoals: learningGoals.length > 0
+ ? learningGoals
+ : [
+ 'Die Schlüsselausdrücke der Lektion verstehen und wiedererkennen.',
+ 'Ein bis zwei Satzmuster aktiv anwenden.',
+ 'Kurze Sätze oder Mini-Dialoge zum Thema selbst bilden.'
+ ],
+ corePatterns: corePatterns.length > 0 ? corePatterns : uniquePatterns.slice(0, 5),
+ grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4),
+ speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4),
+ practicalTasks: practicalTasks.length > 0
+ ? practicalTasks
+ : [
+ {
+ title: 'Mini-Anwendung',
+ text: 'Formuliere zwei bis drei eigene Sätze oder einen kurzen Dialog mit dem Muster dieser Lektion.'
+ }
+ ]
+ };
+ }
+
async _getLanguageAccess(userId, languageId) {
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
@@ -895,15 +1015,7 @@ export default class VocabService {
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
}
- console.log(`[getLesson] Lektion ${lessonId} geladen:`, {
- id: plainLesson.id,
- title: plainLesson.title,
- lessonType: plainLesson.lessonType,
- exerciseCount: plainLesson.grammarExercises ? plainLesson.grammarExercises.length : 0,
- reviewLessonsCount: plainLesson.reviewLessons ? plainLesson.reviewLessons.length : 0,
- reviewVocabExercisesCount: plainLesson.reviewVocabExercises ? plainLesson.reviewVocabExercises.length : 0,
- previousLessonExercisesCount: plainLesson.previousLessonExercises ? plainLesson.previousLessonExercises.length : 0
- });
+ plainLesson.didactics = this._buildLessonDidactics(plainLesson);
return plainLesson;
}
@@ -975,7 +1087,7 @@ export default class VocabService {
return exercises.map(e => e.get({ plain: true }));
}
- async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
+ async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
@@ -1019,6 +1131,11 @@ export default class VocabService {
lessonType: lessonType || 'vocab',
audioUrl: audioUrl || null,
culturalNotes: culturalNotes || null,
+ learningGoals: this._normalizeStringList(learningGoals),
+ corePatterns: this._normalizeStringList(corePatterns),
+ grammarFocus: this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']),
+ speakingPrompts: this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']),
+ practicalTasks: this._normalizeStructuredList(practicalTasks, ['title', 'text']),
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
@@ -1027,7 +1144,7 @@ export default class VocabService {
return lesson.get({ plain: true });
}
- async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
+ async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
@@ -1054,6 +1171,11 @@ export default class VocabService {
if (lessonType !== undefined) updates.lessonType = lessonType;
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
+ if (learningGoals !== undefined) updates.learningGoals = this._normalizeStringList(learningGoals);
+ if (corePatterns !== undefined) updates.corePatterns = this._normalizeStringList(corePatterns);
+ if (grammarFocus !== undefined) updates.grammarFocus = this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']);
+ if (speakingPrompts !== undefined) updates.speakingPrompts = this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']);
+ if (practicalTasks !== undefined) updates.practicalTasks = this._normalizeStructuredList(practicalTasks, ['title', 'text']);
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
@@ -1450,6 +1572,15 @@ export default class VocabService {
correctAnswer = questionData.expectedText || questionData.text || '';
alternatives = questionData.keywords || [];
}
+ else if (questionData.type === 'sentence_building' || questionData.type === 'dialog_completion' || questionData.type === 'situational_response' || questionData.type === 'pattern_drill') {
+ const rawCorrect = answerData.correct ?? answerData.correctAnswer ?? answerData.answers ?? answerData.modelAnswer;
+ if (Array.isArray(rawCorrect)) {
+ correctAnswer = rawCorrect.join(' / ');
+ } else {
+ correctAnswer = rawCorrect || questionData.modelAnswer || '';
+ }
+ alternatives = answerData.alternatives || questionData.keywords || [];
+ }
// Fallback: Versuche correct oder correctAnswer
else {
correctAnswer = Array.isArray(answerData.correct)
@@ -1531,10 +1662,9 @@ export default class VocabService {
// Für Reading Aloud: userAnswer ist der erkannte Text (String)
// Vergleiche mit dem erwarteten Text aus questionData.text
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') {
- const normalize = (str) => String(str || '').trim().toLowerCase().replace(/[.,!?;:]/g, '');
const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
- const normalizedExpected = normalize(expectedText);
- const normalizedUser = normalize(userAnswer);
+ const normalizedExpected = this._normalizeTextAnswer(expectedText);
+ const normalizedUser = this._normalizeTextAnswer(userAnswer);
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
if (parsedQuestionData.type === 'reading_aloud') {
@@ -1550,16 +1680,33 @@ export default class VocabService {
return normalizedUser === normalizedExpected;
}
// Prüfe ob alle Schlüsselwörter vorhanden sind
- return keywords.every(keyword => normalizedUser.includes(normalize(keyword)));
+ return keywords.every(keyword => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
}
}
+ if (parsedQuestionData.type === 'sentence_building' || parsedQuestionData.type === 'dialog_completion' || parsedQuestionData.type === 'situational_response' || parsedQuestionData.type === 'pattern_drill') {
+ const candidateAnswers = parsedAnswerData.correct ?? parsedAnswerData.correctAnswer ?? parsedAnswerData.answers ?? parsedAnswerData.modelAnswer ?? [];
+ const normalizedUser = this._normalizeTextAnswer(userAnswer);
+ const answers = Array.isArray(candidateAnswers) ? candidateAnswers : [candidateAnswers];
+
+ if (parsedQuestionData.type === 'situational_response') {
+ const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || [];
+ if (keywords.length > 0) {
+ return keywords.every((keyword) => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
+ }
+ }
+
+ return answers
+ .map((answer) => this._normalizeTextAnswer(answer))
+ .filter(Boolean)
+ .some((answer) => answer === normalizedUser);
+ }
+
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
- const normalize = (str) => String(str || '').trim().toLowerCase();
const correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
- const normalizedUserAnswer = normalize(userAnswer);
- return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer);
+ const normalizedUserAnswer = this._normalizeTextAnswer(userAnswer);
+ return correctAnswersArray.some(correct => this._normalizeTextAnswer(correct) === normalizedUserAnswer);
}
async getGrammarExerciseProgress(hashedUserId, lessonId) {
@@ -1638,5 +1785,3 @@ export default class VocabService {
return { success: true };
}
}
-
-
diff --git a/backend/sql/add_language_assistant_user_params.sql b/backend/sql/add_language_assistant_user_params.sql
new file mode 100644
index 0000000..1a1279a
--- /dev/null
+++ b/backend/sql/add_language_assistant_user_params.sql
@@ -0,0 +1,24 @@
+-- Sprachassistent / LLM: Einstellungen über type.settings + type.user_param + community.user_param
+-- (keine Spalten mehr an community.user).
+--
+-- Falls du vorher add_user_llm_columns.sql ausgeführt hast: Spalten an user wieder entfernen.
+ALTER TABLE community."user" DROP COLUMN IF EXISTS llm_api_key_encrypted;
+ALTER TABLE community."user" DROP COLUMN IF EXISTS llm_settings;
+
+-- Gruppe „languageAssistant“
+INSERT INTO type.settings (name)
+SELECT 'languageAssistant'
+WHERE NOT EXISTS (SELECT 1 FROM type.settings WHERE name = 'languageAssistant');
+
+-- Param-Typen (description eindeutig)
+INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
+SELECT 'llm_settings', 'string', s.id, 900, false, NULL, NULL, NULL
+FROM type.settings s
+WHERE s.name = 'languageAssistant'
+ AND NOT EXISTS (SELECT 1 FROM type.user_param p WHERE p.description = 'llm_settings');
+
+INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
+SELECT 'llm_api_key', 'string', s.id, 901, false, NULL, NULL, NULL
+FROM type.settings s
+WHERE s.name = 'languageAssistant'
+ AND NOT EXISTS (SELECT 1 FROM type.user_param p WHERE p.description = 'llm_api_key');
diff --git a/backend/sql/add_vocab_lesson_didactics.sql b/backend/sql/add_vocab_lesson_didactics.sql
new file mode 100644
index 0000000..058e96e
--- /dev/null
+++ b/backend/sql/add_vocab_lesson_didactics.sql
@@ -0,0 +1,14 @@
+ALTER TABLE community.vocab_course_lesson
+ADD COLUMN IF NOT EXISTS learning_goals JSONB,
+ADD COLUMN IF NOT EXISTS core_patterns JSONB,
+ADD COLUMN IF NOT EXISTS grammar_focus JSONB,
+ADD COLUMN IF NOT EXISTS speaking_prompts JSONB,
+ADD COLUMN IF NOT EXISTS practical_tasks JSONB;
+
+INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
+ ('dialog_completion', 'Dialogergänzung'),
+ ('situational_response', 'Situative Antwort'),
+ ('pattern_drill', 'Muster-Drill'),
+ ('reading_aloud', 'Lautlese-Übung'),
+ ('speaking_from_memory', 'Freies Sprechen')
+ON CONFLICT (name) DO NOTHING;
diff --git a/backend/sql/create-vocab-courses.sql b/backend/sql/create-vocab-courses.sql
index 996e4cf..3cb3efc 100644
--- a/backend/sql/create-vocab-courses.sql
+++ b/backend/sql/create-vocab-courses.sql
@@ -44,6 +44,11 @@ CREATE TABLE IF NOT EXISTS community.vocab_course_lesson (
lesson_type TEXT DEFAULT 'vocab',
audio_url TEXT,
cultural_notes TEXT,
+ learning_goals JSONB,
+ core_patterns JSONB,
+ grammar_focus JSONB,
+ speaking_prompts JSONB,
+ practical_tasks JSONB,
target_minutes INTEGER,
target_score_percent INTEGER DEFAULT 80,
requires_review BOOLEAN DEFAULT false,
@@ -219,7 +224,12 @@ INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
('sentence_building', 'Satzbau-Übung'),
('transformation', 'Satzumformung'),
('conjugation', 'Konjugations-Übung'),
- ('declension', 'Deklinations-Übung')
+ ('declension', 'Deklinations-Übung'),
+ ('dialog_completion', 'Dialogergänzung'),
+ ('situational_response', 'Situative Antwort'),
+ ('pattern_drill', 'Muster-Drill'),
+ ('reading_aloud', 'Lautlese-Übung'),
+ ('speaking_from_memory', 'Freies Sprechen')
ON CONFLICT (name) DO NOTHING;
-- ============================================
@@ -230,6 +240,16 @@ COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS
'Type: vocab, grammar, conversation, culture, review';
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
'Zielzeit in Minuten für diese Lektion';
+COMMENT ON COLUMN community.vocab_course_lesson.learning_goals IS
+ 'Lernziele der Lektion als JSON-Array';
+COMMENT ON COLUMN community.vocab_course_lesson.core_patterns IS
+ 'Kernmuster und Beispielsätze als JSON-Array';
+COMMENT ON COLUMN community.vocab_course_lesson.grammar_focus IS
+ 'Grammatik-Impulse als JSON-Array von Objekten';
+COMMENT ON COLUMN community.vocab_course_lesson.speaking_prompts IS
+ 'Sprechaufträge als JSON-Array von Objekten';
+COMMENT ON COLUMN community.vocab_course_lesson.practical_tasks IS
+ 'Praxisaufgaben als JSON-Array von Objekten';
COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS
diff --git a/backend/sql/update-vocab-courses-existing.sql b/backend/sql/update-vocab-courses-existing.sql
index 0b94936..e6073a8 100644
--- a/backend/sql/update-vocab-courses-existing.sql
+++ b/backend/sql/update-vocab-courses-existing.sql
@@ -19,6 +19,11 @@ ADD COLUMN IF NOT EXISTS day_number INTEGER,
ADD COLUMN IF NOT EXISTS lesson_type TEXT DEFAULT 'vocab',
ADD COLUMN IF NOT EXISTS audio_url TEXT,
ADD COLUMN IF NOT EXISTS cultural_notes TEXT,
+ADD COLUMN IF NOT EXISTS learning_goals JSONB,
+ADD COLUMN IF NOT EXISTS core_patterns JSONB,
+ADD COLUMN IF NOT EXISTS grammar_focus JSONB,
+ADD COLUMN IF NOT EXISTS speaking_prompts JSONB,
+ADD COLUMN IF NOT EXISTS practical_tasks JSONB,
ADD COLUMN IF NOT EXISTS target_minutes INTEGER,
ADD COLUMN IF NOT EXISTS target_score_percent INTEGER DEFAULT 80,
ADD COLUMN IF NOT EXISTS requires_review BOOLEAN DEFAULT false;
@@ -111,7 +116,12 @@ INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
('sentence_building', 'Satzbau-Übung'),
('transformation', 'Satzumformung'),
('conjugation', 'Konjugations-Übung'),
- ('declension', 'Deklinations-Übung')
+ ('declension', 'Deklinations-Übung'),
+ ('dialog_completion', 'Dialogergänzung'),
+ ('situational_response', 'Situative Antwort'),
+ ('pattern_drill', 'Muster-Drill'),
+ ('reading_aloud', 'Lautlese-Übung'),
+ ('speaking_from_memory', 'Freies Sprechen')
ON CONFLICT (name) DO NOTHING;
-- ============================================
@@ -121,6 +131,16 @@ COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS
'Type: vocab, grammar, conversation, culture, review';
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
'Zielzeit in Minuten für diese Lektion';
+COMMENT ON COLUMN community.vocab_course_lesson.learning_goals IS
+ 'Lernziele der Lektion als JSON-Array';
+COMMENT ON COLUMN community.vocab_course_lesson.core_patterns IS
+ 'Kernmuster und Beispielsätze als JSON-Array';
+COMMENT ON COLUMN community.vocab_course_lesson.grammar_focus IS
+ 'Grammatik-Impulse als JSON-Array von Objekten';
+COMMENT ON COLUMN community.vocab_course_lesson.speaking_prompts IS
+ 'Sprechaufträge als JSON-Array von Objekten';
+COMMENT ON COLUMN community.vocab_course_lesson.practical_tasks IS
+ 'Praxisaufgaben als JSON-Array von Objekten';
COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS
diff --git a/backend/utils/initializeSettings.js b/backend/utils/initializeSettings.js
index fa29d3b..e28d15e 100644
--- a/backend/utils/initializeSettings.js
+++ b/backend/utils/initializeSettings.js
@@ -17,6 +17,10 @@ const initializeSettings = async () => {
where: { name: 'flirt' },
defaults: { name: 'flirt' }
});
+ await SettingsType.findOrCreate({
+ where: { name: 'languageAssistant' },
+ defaults: { name: 'languageAssistant' }
+ });
};
export default initializeSettings;
\ No newline at end of file
diff --git a/backend/utils/initializeTypes.js b/backend/utils/initializeTypes.js
index fc89467..c2aa97e 100644
--- a/backend/utils/initializeTypes.js
+++ b/backend/utils/initializeTypes.js
@@ -46,6 +46,8 @@ const initializeTypes = async () => {
willChildren: { type: 'bool', setting: 'flirt', minAge: 14 },
smokes: { type: 'singleselect', setting: 'flirt', minAge: 14},
drinks: { type: 'singleselect', setting: 'flirt', minAge: 14 },
+ llm_settings: { type: 'string', setting: 'languageAssistant' },
+ llm_api_key: { type: 'string', setting: 'languageAssistant' },
};
let orderId = 1;
for (const key of Object.keys(userParams)) {
diff --git a/docs/VOCAB_TRAINER_DIDACTIC_CONCEPT.md b/docs/VOCAB_TRAINER_DIDACTIC_CONCEPT.md
new file mode 100644
index 0000000..647308d
--- /dev/null
+++ b/docs/VOCAB_TRAINER_DIDACTIC_CONCEPT.md
@@ -0,0 +1,647 @@
+# Vokabeltrainer: Didaktisches Konzept mit Praxisfokus
+
+## 1. Zielbild
+
+Der aktuelle Vokabeltrainer ist als Inhaltscontainer brauchbar, aber als Sprachlernsystem noch zu stark auf einzelne Wörter und simple Abfragen reduziert.
+
+Das Ziel ist kein reiner Karteikasten, sondern ein lernbarer Sprachkurs mit:
+
+- aktivem Verstehen
+- aktiver Produktion
+- Sprechaufforderungen
+- klarer Grammatikführung
+- kurzen, wiederholbaren Übungsblöcken
+- alltagsnahen Dialogen
+
+Der Lernende soll nicht nur Wörter erkennen, sondern Sätze bilden, typische Muster wiederverwenden und Sprache mündlich vorbereiten können.
+
+## 2. Kernprobleme des aktuellen Stands
+
+### 2.1 Zu viel Vokabelabfrage, zu wenig Sprachhandlung
+
+Der aktuelle Aufbau wirkt, als ob Lernen vor allem bedeutet:
+
+- Wort sehen
+- Übersetzung wählen
+- nächste Vokabel
+
+Das reicht für passives Wiedererkennen, aber nicht für aktives Sprechen.
+
+### 2.2 Grammatik ist nicht als Lernhilfe integriert
+
+Grammatik taucht bisher eher als eigener Inhaltspunkt auf statt als direkt nutzbare Erklärung.
+
+Fehlt:
+
+- kurze Regel
+- 2 bis 4 gute Beispiele
+- Mini-Transformation
+- direkte Anwendung im Satz
+
+### 2.3 Sprechen ist nicht ernsthaft eingebaut
+
+Für eine Sprache wie Bisaya reicht stilles Lesen nicht.
+
+Es braucht:
+
+- laut nachsprechen
+- Satzmuster wiederholen
+- kurze Antwortaufgaben
+- Mini-Dialoge
+- bewusste Aussprachehinweise
+
+### 2.4 Übungen sind zu monoton
+
+Wenn fast alles Multiple Choice oder reine Vokabelabfrage ist, entsteht:
+
+- wenig Transfer
+- wenig aktive Erinnerung
+- wenig Satzgefühl
+- zu geringe Motivation
+
+## 3. Didaktische Leitprinzipien
+
+### 3.1 Vom Gebrauch aus denken
+
+Jede Lektion soll zuerst die Frage beantworten:
+
+- Was soll der Lernende danach konkret sagen können?
+
+Nicht:
+
+- Welche Wörter kommen vor?
+
+Sondern:
+
+- Welche Alltagssituation wird beherrschbar?
+
+### 3.2 Erst Muster, dann Ausbau
+
+Menschen lernen Sprache im Alltag oft über wiederkehrende Muster:
+
+- Begrüßen
+- Fragen
+- Antworten
+- Bitten
+- Reagieren
+
+Darum soll jede Lektion 1 bis 3 tragende Satzmuster haben.
+
+### 3.3 Kleine Grammatik, sofort angewandt
+
+Grammatik soll kurz, klar und funktional sein:
+
+- Regel in einem Satz
+- 2 gute Beispiele
+- 1 Gegenbeispiel oder Stolperstein
+- dann direkte Übung
+
+### 3.4 Aktive Produktion in jeder Lektion
+
+Jede Lektion braucht mindestens eine Aufgabe, in der der Lernende selbst produziert:
+
+- Wort einsetzen
+- Satz umformen
+- Antwort formulieren
+- Dialog ergänzen
+- laut sprechen
+
+### 3.5 Wiederholung als Spiralmodell
+
+Wiederholung soll nicht nur „nochmal dieselben Wörter“ heißen.
+
+Wiederholung muss variieren:
+
+- zuerst Erkennen
+- dann Erinnern
+- dann Anwenden
+- dann freies Reagieren
+
+## 4. Neuer Aufbau einer Lektion
+
+Jede Lektion sollte einem festen, didaktisch sinnvollen Raster folgen.
+
+### 4.1 Abschnitt A: Einstieg
+
+Ziel:
+
+- Thema aktivieren
+- Nutzwert klären
+- Leitmuster setzen
+
+Inhalt:
+
+- 1 kurzer Alltagskontext
+- 2 bis 4 Leitsätze
+- Audio oder Sprechhinweis
+
+Beispiel:
+
+- „Heute lernst du, wie du auf Bisaya nach dem Befinden fragst und fürsorglich reagierst.“
+
+### 4.2 Abschnitt B: Kernvokabular
+
+Nicht zu groß.
+
+Empfehlung:
+
+- 8 bis 15 neue Einheiten
+- nicht nur Einzelwörter
+- auch feste Wendungen und Halbsätze
+
+Beispiel:
+
+- nicht nur `kaon = essen`
+- sondern auch `Nikaon ka? = Hast du schon gegessen?`
+
+### 4.3 Abschnitt C: Grammatik-Impuls
+
+Kurz und direkt.
+
+Empfehlung pro Lektion:
+
+- genau 1 Hauptregel
+- optional 1 Nebenhinweis
+
+Format:
+
+1. Was ist das Muster?
+2. Wie bildet man es?
+3. Zwei gute Beispiele
+4. Ein typischer Fehler
+
+### 4.4 Abschnitt D: Gesteuerte Übung
+
+Hier wird Sicherheit aufgebaut.
+
+Übungstypen:
+
+- Zuordnen
+- Lückentext
+- einfache Umformung
+- Satzbausteine ordnen
+
+### 4.5 Abschnitt E: Aktive Sprachproduktion
+
+Pflichtblock.
+
+Übungstypen:
+
+- antworte mit einem kurzen Satz
+- ergänze einen Dialog
+- formuliere eine passende Reaktion
+- sprich die drei Beispielsätze laut
+
+### 4.6 Abschnitt F: Mini-Sprechauftrag
+
+Auch ohne automatische Spracherkennung wertvoll.
+
+Der Kurs fordert aktiv auf:
+
+- „Sprich den Satz dreimal laut.“
+- „Beantworte die Frage frei.“
+- „Lies den Dialog mit verteilten Rollen.“
+
+Optional später:
+
+- Aufnahmefunktion
+- Selbstbewertung
+- Tutor-/Partnerabgleich
+
+### 4.7 Abschnitt G: Lernabschluss
+
+Zum Ende jeder Lektion:
+
+- Was kannst du jetzt sagen?
+- 3 Wiederholungssätze
+- 1 Mini-Selbsttest
+
+## 5. Verbesserte Übungstypen
+
+Die Vokabelübungen sollten nicht ersetzt, sondern erweitert werden.
+
+### 5.1 Grundtypen
+
+- `recognition`
+ - richtige Übersetzung erkennen
+- `recall`
+ - Wort oder Wendung aktiv erinnern
+- `spelling`
+ - Schreibform festigen
+- `listening_prompt`
+ - Audio hören, Bedeutung erfassen
+- `speaking_prompt`
+ - Satz laut sprechen
+
+### 5.2 Satz- und Strukturtypen
+
+- `sentence_building`
+ - Bausteine in richtige Reihenfolge bringen
+- `gap_fill`
+ - fehlendes Wort oder Morphem einsetzen
+- `transformation`
+ - Aussage in Frage oder Antwort umformen
+- `response_choice`
+ - passende Reaktion auswählen
+- `dialog_completion`
+ - fehlende Dialogzeile ergänzen
+
+### 5.3 Freiere Übungstypen
+
+- `micro_translation`
+ - kurzen alltagsnahen Satz übersetzen
+- `situational_response`
+ - Was würdest du hier sagen?
+- `shadowing`
+ - Satz nachsprechen und Rhythmus übernehmen
+- `pattern_drill`
+ - dieselbe Struktur mit wechselnden Inhalten
+
+## 6. Grammatik-Konzept
+
+Grammatik soll in drei Ebenen organisiert werden.
+
+### 6.1 Ebene 1: Sofort nutzbare Muster
+
+Beispiele:
+
+- Fragen nach dem Befinden
+- Besitz ausdrücken
+- einfache Zeitbezüge
+- höfliche Bitte
+
+### 6.2 Ebene 2: Häufige Strukturbausteine
+
+Beispiele:
+
+- Personalpronomen
+- Fragepartikel
+- häufige Verbmuster
+- Negation
+
+### 6.3 Ebene 3: Vertiefung
+
+Erst später:
+
+- Variation
+- Register
+- Kontraste
+- Sonderfälle
+
+Wichtig:
+
+- Anfänger sollen nicht mit Vollständigkeit überlastet werden
+- lieber funktional korrekt als theoretisch vollständig
+
+## 7. Sprechen systematisch einbauen
+
+Auch ohne automatische Bewertung kann Sprechen systematisch geübt werden.
+
+### 7.1 Pflicht-Sprechmomente
+
+Jede Lektion soll enthalten:
+
+- 3 bis 5 Sätze zum Nachsprechen
+- 1 kurze freie Antwort
+- 1 Mini-Dialog
+
+### 7.2 Sichtbare Sprechmarker
+
+UI-Idee:
+
+- eigener Block `Sprich jetzt`
+- Mikrofon-Symbol auch ohne Aufnahmefunktion
+- Timer oder Wiederholungszähler
+
+### 7.3 Aussprachehilfen
+
+Gerade bei Bisaya wichtig:
+
+- einfache Lautumschrift
+- Betonungshinweis
+- keine überladene Phonetik, sondern pragmatische Hilfen
+
+## 8. Fortschrittsmodell
+
+Nicht nur Prozent und „bestanden“.
+
+Sinnvoll sind getrennte Fortschrittsspuren:
+
+- Wortschatz
+- Satzmuster
+- Grammatikmuster
+- Hörverstehen
+- Sprechpraxis
+
+So kann ein Lernender sehen:
+
+- Ich erkenne Wörter schon gut
+- aber ich antworte noch zu unsicher
+
+## 9. Beispielkurs: Bisaya
+
+Bisaya eignet sich besonders gut für einen praxisnahen Kurs, weil Alltag, Familie und Fürsorge sprachlich sehr stark über feste Muster laufen.
+
+### 9.1 Leitidee
+
+Nicht:
+
+- erst viele isolierte Wörter
+
+Sondern:
+
+- von Anfang an nützliche, warme Alltagskommunikation
+
+### 9.2 Frühe Kernbereiche
+
+Empfohlene Startthemen:
+
+- Begrüßung und Befinden
+- Fürsorge und Nachfragen
+- Familie
+- Essen und Trinken
+- Bitten und Höflichkeit
+- Wege, Orte, Alltag
+
+### 9.3 Beispiel: Lektion „Wie geht es dir?“
+
+#### Lernziel
+
+Der Lernende soll:
+
+- fragen können, wie es jemandem geht
+- kurz antworten können
+- fürsorglich reagieren können
+
+#### Kernmuster
+
+- `Kumusta ka?`
+- `Maayo ra.`
+- `Nikaon ka?`
+- `Salamat.`
+
+#### Grammatik-Impuls
+
+Fokus:
+
+- kurze Standardantworten ohne unnötige Theorie
+- `ka` als direkte Anrede im einfachen Muster
+
+#### Übungen
+
+- Multiple Choice: Was bedeutet `Kumusta ka?`
+- Satzbau: `ka / Kumusta`
+- Dialogergänzung:
+ - A: `Kumusta ka?`
+ - B: `_____`
+- Sprechauftrag:
+ - „Sprich `Kumusta ka?` dreimal laut.“
+ - „Beantworte frei: `Maayo ra` oder `Okay ra`.“
+
+### 9.4 Beispiel: Lektion „Fürsorge im Familienalltag“
+
+#### Ziel
+
+Typische fürsorgliche Fragen und Reaktionen lernen.
+
+#### Kernmuster
+
+- `Nikaon ka?`
+- `Palihug kaon.`
+- `Kapoy ka?`
+- `Pahuway sa.`
+
+#### Grammatik
+
+- Imperativ und fürsorgliche Aufforderung in einfacher Form
+
+#### Praktische Übung
+
+- Reaktion passend auswählen
+- kurze Fürsorge-Sätze selbst bilden
+- Mini-Rollenspiel:
+ - Mutter
+ - Kind
+ - Besuch
+
+### 9.5 Beispiel: Lektion „Familie“
+
+Nicht nur Vokabelliste:
+
+- `nanay`, `tatay`, `ate`, `kuya`, `lola`, `lolo`
+
+Sondern auch Anwendung:
+
+- `Asa si Nanay?`
+- `Si Kuya naa sa balay.`
+- `Mingaw ko nimo.`
+
+## 10. Struktur für einen besseren Bisaya-Kurs
+
+### Phase 1: Sofort sprechen
+
+Ziel:
+
+- die ersten 20 bis 30 hochrelevanten Wendungen sicher nutzen
+
+### Phase 2: Alltag führen
+
+Ziel:
+
+- kurze Familien- und Alltagssituationen bewältigen
+
+### Phase 3: Sicherer reagieren
+
+Ziel:
+
+- verstehen, variieren, Rückfragen stellen
+
+### Phase 4: Freier Alltag
+
+Ziel:
+
+- kleine Dialoge ohne starres Vorbild
+
+## 10.1 Empfohlene Lektionslängen
+
+Die aktuellen Lektionen wirken oft zu kurz, nicht nur zeitlich, sondern vor allem in ihrer didaktischen Substanz.
+
+Sinnvoll ist eine klare Trennung nach Lektionstyp:
+
+### Mikro-Lektion
+
+Einsatz:
+
+- Wiederholung
+- Auffrischung
+- kurzer Tagesimpuls
+
+Empfohlene Dauer:
+
+- `5 bis 8 Minuten`
+
+Typischer Inhalt:
+
+- 1 Leitmuster
+- 4 bis 6 Wiederholungsaufgaben
+- 1 kurzer Sprechimpuls
+
+### Standard-Lektion
+
+Einsatz:
+
+- neues Alltagsthema
+- neues Grammatikmuster
+- neue Kernvokabeln mit echter Anwendung
+
+Empfohlene Dauer:
+
+- `12 bis 20 Minuten`
+
+Typischer Inhalt:
+
+- Einstieg
+- 8 bis 15 neue Einheiten
+- 1 Grammatik-Impuls
+- 4 bis 8 Übungen
+- 1 aktiver Sprachteil
+- 1 Abschlussblock
+
+### Praxis-/Review-Lektion
+
+Einsatz:
+
+- Wochenabschluss
+- Wiederholung mehrerer Themen
+- Mini-Dialoge
+- Transfer in Alltagssituationen
+
+Empfohlene Dauer:
+
+- `15 bis 25 Minuten`
+
+Typischer Inhalt:
+
+- Wiederholung mehrerer Muster
+- Dialogergänzungen
+- Situationsaufgaben
+- mehrere Sprechaufträge
+- kleiner Abschlusstest
+
+## 10.2 Mindestumfang einer guten Standard-Lektion
+
+Eine normale Lektion sollte nicht nur aus zwei oder drei Aufgaben bestehen.
+
+Empfohlener Mindestumfang:
+
+- 1 klares Lernziel
+- 2 bis 4 Kernmuster
+- 8 bis 15 neue Wörter oder Wendungen
+- 1 kurzer Grammatik-Impuls
+- mindestens 4 Übungsaufgaben
+- mindestens 1 Aufgabe mit aktiver Satzproduktion
+- mindestens 1 Sprechaufforderung
+- 1 Mini-Abschluss oder Selbsttest
+
+Damit fühlt sich eine Lektion nicht mehr wie ein Fragment an, sondern wie ein echter Lernschritt.
+
+## 10.3 Empfehlung für Bisaya
+
+Für Bisaya sind besonders geeignet:
+
+- viele Standard-Lektionen im Bereich `12 bis 18 Minuten`
+- dazwischen kurze Mikro-Wiederholungen
+- pro Woche eine längere Praxis-/Review-Lektion
+
+Warum:
+
+- Bisaya profitiert stark von wiederkehrenden Mustern
+- Familien- und Alltagssprache lässt sich sehr gut in mittleren, thematisch dichten Lerneinheiten aufbauen
+- zu kurze Lektionen schneiden genau die Teile ab, die wichtig wären:
+ - Reaktion
+ - Variation
+ - Sprechen
+ - Dialog
+
+## 11. Umsetzung im System
+
+### 11.1 Neue didaktische Metadaten für Lektionen
+
+Sinnvoll wären zusätzliche Felder:
+
+- `canDoAfterLesson`
+- `corePatterns`
+- `grammarFocus`
+- `speakingPrompts`
+- `reviewMode`
+
+### 11.2 Neue Übungstypen
+
+Mindestens ergänzen:
+
+- `sentence_building`
+- `dialog_completion`
+- `situational_response`
+- `speaking_prompt`
+- `pattern_drill`
+
+### 11.3 Bewertungslogik
+
+Nicht nur eine Gesamtquote.
+
+Sinnvoll:
+
+- Vokabeltreffer
+- Satzmuster
+- Grammatik
+- aktive Produktion
+
+### 11.4 Wiederholungslogik
+
+Wiederholen je nach Schwäche:
+
+- Wortschatz schwach -> Recall/Vokabel
+- Grammatik schwach -> Transformation
+- Sprechen schwach -> Sprechblock erneut
+
+## 12. Priorisierte Verbesserungsschritte
+
+### Stufe 1: Sofort sinnvoll
+
+- jede Lektion bekommt Lernziel
+- jede Lektion bekommt Kernmuster
+- jede Lektion bekommt kurzen Grammatik-Impuls
+- jede Lektion bekommt einen Sprechauftrag
+- Vokabelübungen werden um Satzbau und Dialogergänzung ergänzt
+
+### Stufe 2: Didaktisch deutlich besser
+
+- Fortschritt getrennt nach Kompetenzen
+- bessere Review-Logik
+- alltagsnahe Mini-Dialoge
+- Bisaya-Kurse nach Themen statt nur nach Vokabelgruppen schärfen
+
+### Stufe 3: Später Ausbau
+
+- Audio
+- Aufnahmefunktion
+- automatische Ausspracheunterstützung
+- freiere Antwortbewertung
+
+## 13. Fazit
+
+Der Vokabeltrainer sollte von einem reinen Lernkarten-System zu einem praktischen Sprachlern-System werden.
+
+Die wichtigste Verschiebung ist:
+
+- weg von isolierten Vokabeln
+- hin zu Mustern, Reaktionen, Sprechen und kurzen brauchbaren Dialogen
+
+Gerade mit Bisaya lässt sich das sehr gut umsetzen, weil die Sprache im Familien- und Alltagskontext stark über konkrete, wiederkehrende Wendungen vermittelt werden kann.
+
+Der Kurs muss sich deshalb nicht „größer“, sondern „lebendiger“ anfühlen:
+
+- weniger bloße Abfrage
+- mehr echte Verwendung
+- mehr kurze Sprechhandlungen
+- mehr verständliche Grammatik
diff --git a/frontend/src/components/AppSectionBar.vue b/frontend/src/components/AppSectionBar.vue
index ed49906..bb41efa 100644
--- a/frontend/src/components/AppSectionBar.vue
+++ b/frontend/src/components/AppSectionBar.vue
@@ -65,6 +65,7 @@ const TITLE_MAP = {
'Sexuality settings': 'Sexualität',
'Flirt settings': 'Flirt',
'Account settings': 'Account',
+ 'Language assistant settings': 'Sprachassistent',
Interests: 'Interessen',
AdminInterests: 'Interessenverwaltung',
AdminUsers: 'Benutzer',
diff --git a/frontend/src/i18n/locales/de/navigation.json b/frontend/src/i18n/locales/de/navigation.json
index 97f01ad..b47e0d4 100644
--- a/frontend/src/i18n/locales/de/navigation.json
+++ b/frontend/src/i18n/locales/de/navigation.json
@@ -61,7 +61,8 @@
"flirt": "Flirt",
"interests": "Interessen",
"notifications": "Benachrichtigungen",
- "sexuality": "Sexualität"
+ "sexuality": "Sexualität",
+ "languageAssistant": "Sprachassistent"
},
"m-administration": {
"contactrequests": "Kontaktanfragen",
diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json
index bf9dcc7..1f9b868 100644
--- a/frontend/src/i18n/locales/de/settings.json
+++ b/frontend/src/i18n/locales/de/settings.json
@@ -150,6 +150,27 @@
"changeaction": "Benutzerdaten ändern",
"oldpassword": "Altes Passwort (benötigt)"
},
+ "languageAssistant": {
+ "eyebrow": "Einstellungen",
+ "title": "Sprachassistent & KI",
+ "intro": "Hier kannst du einen eigenen API-Zugang hinterlegen (z. B. OpenAI), den die Plattform für Sprachkurs-Funktionen nutzen kann. Der Schlüssel wird serverseitig verschlüsselt gespeichert; du benötigst ein Konto beim jeweiligen Anbieter.",
+ "linkSignup": "Konto bei OpenAI anlegen (neues Fenster)",
+ "linkApiKeys": "API-Keys bei OpenAI verwalten (neues Fenster)",
+ "enabled": "Nutzung für Sprachfunktionen erlauben",
+ "baseUrl": "API-Basis-URL (optional)",
+ "baseUrlPlaceholder": "Leer = Standard (OpenAI). Für Ollama z. B. http://127.0.0.1:11434/v1",
+ "model": "Modellname",
+ "apiKey": "API-Schlüssel",
+ "apiKeyHint": "Leer lassen, um den gespeicherten Schlüssel beizubehalten.",
+ "apiKeyPlaceholderNew": "Neuen Schlüssel einfügen",
+ "apiKeyPlaceholderHasKey": "Gespeichert endet auf …{last4} — leer lassen behält den Schlüssel",
+ "apiKeyPlaceholderClear": "Speicher wird geleert, wenn du unten „Schlüssel löschen“ speicherst",
+ "clearKey": "Gespeicherten API-Schlüssel entfernen",
+ "save": "Speichern",
+ "saved": "Einstellungen gespeichert.",
+ "saveError": "Speichern fehlgeschlagen.",
+ "confirmClear": "API-Schlüssel wirklich löschen?"
+ },
"interests": {
"title": "Interessen",
"new": "Neues Interesse",
diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json
index bf0d0ab..9e6e1e6 100644
--- a/frontend/src/i18n/locales/de/socialnetwork.json
+++ b/frontend/src/i18n/locales/de/socialnetwork.json
@@ -370,9 +370,16 @@
"learn": "Lernen",
"exercises": "Kapitel-Prüfung",
"learnVocabulary": "Vokabeln lernen",
+ "lessonOverviewText": "Diese Lektion verbindet Vokabeln, Muster, kurze Grammatikimpulse und aktive Sprachpraxis.",
"lessonDescription": "Lektions-Beschreibung",
"culturalNotes": "Kulturelle Notizen",
"grammarExplanations": "Grammatik-Erklärungen",
+ "grammarImpulse": "Grammatik-Impuls",
+ "learningGoals": "Lernziele",
+ "corePatterns": "Kernmuster",
+ "speakingTasks": "Sprechaufträge",
+ "speakingPrompt": "Sprechauftrag",
+ "practicalTasks": "Praxisaufgaben",
"importantVocab": "Wichtige Begriffe",
"vocabInfoText": "Diese Begriffe werden in der Prüfung verwendet. Lerne sie hier passiv, bevor du zur Kapitel-Prüfung wechselst.",
"noVocabInfo": "Lies die Beschreibung oben und die Erklärungen in der Prüfung, um die wichtigsten Begriffe zu lernen.",
@@ -393,12 +400,31 @@
"goToNextLesson": "Zur nächsten Lektion wechseln?",
"allLessonsCompleted": "Alle Lektionen abgeschlossen!",
"startExercises": "Zur Kapitel-Prüfung",
+ "lessonTypeLabel": "Lektionstyp",
+ "recommendedDuration": "Empfohlene Dauer",
+ "exerciseLoad": "Übungsmenge",
+ "exercisesShort": "Übungen",
+ "durationFlexible": "Flexibel",
+ "durationMinutes": "{minutes} Minuten",
+ "lessonTypeVocab": "Wortschatz",
+ "lessonTypeGrammar": "Grammatik",
+ "lessonTypeConversation": "Gespräch",
+ "lessonTypeCulture": "Kultur",
+ "lessonTypeReview": "Wiederholung",
"correctAnswer": "Richtige Antwort",
"alternatives": "Alternative Antworten",
"notStarted": "Nicht begonnen",
"continueCurrentLesson": "Zur aktuellen Lektion",
"previousLessonRequired": "Bitte schließe zuerst die vorherige Lektion ab",
"lessonNumberShort": "#",
+ "buildSentencePlaceholder": "Baue hier deinen Satz",
+ "completeDialogPlaceholder": "Ergänze die fehlende Dialogzeile",
+ "situationalResponsePlaceholder": "Formuliere deine Antwort auf die Situation",
+ "patternDrillPlaceholder": "Formuliere einen passenden Satz mit dem Muster",
+ "modelSentence": "Modellsatz",
+ "modelDialogLine": "Mögliche Dialogzeile",
+ "modelResponse": "Mögliche Antwort",
+ "patternPrompt": "Muster",
"readingAloudInstruction": "Lies den Text laut vor. Klicke auf 'Aufnahme starten' und beginne zu sprechen.",
"speakingFromMemoryInstruction": "Sprich frei aus dem Kopf. Verwende die angezeigten Schlüsselwörter.",
"startRecording": "Aufnahme starten",
@@ -415,4 +441,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/i18n/locales/en/navigation.json b/frontend/src/i18n/locales/en/navigation.json
index 3b50bd6..2433782 100644
--- a/frontend/src/i18n/locales/en/navigation.json
+++ b/frontend/src/i18n/locales/en/navigation.json
@@ -61,7 +61,8 @@
"flirt": "Flirt",
"interests": "Interests",
"notifications": "Notifications",
- "sexuality": "Sexuality"
+ "sexuality": "Sexuality",
+ "languageAssistant": "Language assistant"
},
"m-administration": {
"contactrequests": "Contact requests",
diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json
index cc943e6..053e7f0 100644
--- a/frontend/src/i18n/locales/en/settings.json
+++ b/frontend/src/i18n/locales/en/settings.json
@@ -150,6 +150,27 @@
"changeaction": "Change User Data",
"oldpassword": "Old Password (required)"
},
+ "languageAssistant": {
+ "eyebrow": "Settings",
+ "title": "Language assistant & AI",
+ "intro": "Store your own API access (e.g. OpenAI) for language-course features. The key is encrypted on the server. You need an account with the provider.",
+ "linkSignup": "Create an OpenAI account (new tab)",
+ "linkApiKeys": "Manage OpenAI API keys (new tab)",
+ "enabled": "Allow use for language features",
+ "baseUrl": "API base URL (optional)",
+ "baseUrlPlaceholder": "Empty = default (OpenAI). For Ollama e.g. http://127.0.0.1:11434/v1",
+ "model": "Model name",
+ "apiKey": "API key",
+ "apiKeyHint": "Leave empty to keep the stored key.",
+ "apiKeyPlaceholderNew": "Paste new key",
+ "apiKeyPlaceholderHasKey": "Saved key ends with …{last4} — leave empty to keep",
+ "apiKeyPlaceholderClear": "Storage will be cleared when you save with “Remove key” below",
+ "clearKey": "Remove stored API key",
+ "save": "Save",
+ "saved": "Settings saved.",
+ "saveError": "Could not save.",
+ "confirmClear": "Really delete the API key?"
+ },
"interests": {
"title": "Interests",
"new": "New Interest",
diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json
index 2dccfe1..0b5a78e 100644
--- a/frontend/src/i18n/locales/en/socialnetwork.json
+++ b/frontend/src/i18n/locales/en/socialnetwork.json
@@ -370,9 +370,16 @@
"learn": "Learn",
"exercises": "Chapter Test",
"learnVocabulary": "Learn Vocabulary",
+ "lessonOverviewText": "This lesson combines vocabulary, patterns, short grammar impulses, and active speaking practice.",
"lessonDescription": "Lesson Description",
"culturalNotes": "Cultural Notes",
"grammarExplanations": "Grammar Explanations",
+ "grammarImpulse": "Grammar Focus",
+ "learningGoals": "Learning Goals",
+ "corePatterns": "Core Patterns",
+ "speakingTasks": "Speaking Tasks",
+ "speakingPrompt": "Speaking Prompt",
+ "practicalTasks": "Practical Tasks",
"importantVocab": "Important Vocabulary",
"vocabInfoText": "These terms are used in the test. Learn them here passively before switching to the chapter test.",
"noVocabInfo": "Read the description above and the explanations in the test to learn the most important terms.",
@@ -393,12 +400,31 @@
"goToNextLesson": "Go to next lesson?",
"allLessonsCompleted": "All lessons completed!",
"startExercises": "Start Chapter Test",
+ "lessonTypeLabel": "Lesson Type",
+ "recommendedDuration": "Recommended Duration",
+ "exerciseLoad": "Exercise Load",
+ "exercisesShort": "exercises",
+ "durationFlexible": "Flexible",
+ "durationMinutes": "{minutes} minutes",
+ "lessonTypeVocab": "Vocabulary",
+ "lessonTypeGrammar": "Grammar",
+ "lessonTypeConversation": "Conversation",
+ "lessonTypeCulture": "Culture",
+ "lessonTypeReview": "Review",
"correctAnswer": "Correct Answer",
"alternatives": "Alternative Answers",
"notStarted": "Not Started",
"continueCurrentLesson": "Continue Current Lesson",
"previousLessonRequired": "Please complete the previous lesson first",
"lessonNumberShort": "#",
+ "buildSentencePlaceholder": "Build your sentence here",
+ "completeDialogPlaceholder": "Complete the missing dialog line",
+ "situationalResponsePlaceholder": "Write your response to the situation",
+ "patternDrillPlaceholder": "Create a fitting sentence with the pattern",
+ "modelSentence": "Model sentence",
+ "modelDialogLine": "Possible dialog line",
+ "modelResponse": "Possible response",
+ "patternPrompt": "Pattern",
"readingAloudInstruction": "Read the text aloud. Click 'Start Recording' and begin speaking.",
"speakingFromMemoryInstruction": "Speak freely from memory. Use the displayed keywords.",
"startRecording": "Start Recording",
@@ -415,4 +441,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/i18n/locales/es/navigation.json b/frontend/src/i18n/locales/es/navigation.json
index 61afaa5..e83e8e6 100644
--- a/frontend/src/i18n/locales/es/navigation.json
+++ b/frontend/src/i18n/locales/es/navigation.json
@@ -61,7 +61,8 @@
"flirt": "Flirt",
"interests": "Interessen",
"notifications": "Notificaciones",
- "sexuality": "Sexualidad"
+ "sexuality": "Sexualidad",
+ "languageAssistant": "Asistente de idiomas"
},
"m-administration": {
"contactrequests": "Solicitudes de contacto",
diff --git a/frontend/src/i18n/locales/es/settings.json b/frontend/src/i18n/locales/es/settings.json
index a621b4c..09ea7f3 100644
--- a/frontend/src/i18n/locales/es/settings.json
+++ b/frontend/src/i18n/locales/es/settings.json
@@ -150,6 +150,27 @@
"changeaction": "Actualizar datos de usuario",
"oldpassword": "Contraseña anterior (obligatoria)"
},
+ "languageAssistant": {
+ "eyebrow": "Ajustes",
+ "title": "Asistente de idiomas e IA",
+ "intro": "Aquí puedes guardar tu propio acceso API (p. ej. OpenAI) para funciones del curso de idiomas. La clave se guarda cifrada en el servidor; necesitas una cuenta en el proveedor.",
+ "linkSignup": "Crear cuenta en OpenAI (nueva pestaña)",
+ "linkApiKeys": "Gestionar claves API de OpenAI (nueva pestaña)",
+ "enabled": "Permitir uso para funciones de idioma",
+ "baseUrl": "URL base de la API (opcional)",
+ "baseUrlPlaceholder": "Vacío = predeterminado (OpenAI). Para Ollama p. ej. http://127.0.0.1:11434/v1",
+ "model": "Nombre del modelo",
+ "apiKey": "Clave API",
+ "apiKeyHint": "Déjalo vacío para conservar la clave guardada.",
+ "apiKeyPlaceholderNew": "Pegar nueva clave",
+ "apiKeyPlaceholderHasKey": "La clave guardada termina en …{last4} — vacío = conservar",
+ "apiKeyPlaceholderClear": "Se borrará al guardar con «Eliminar clave» abajo",
+ "clearKey": "Eliminar clave API guardada",
+ "save": "Guardar",
+ "saved": "Ajustes guardados.",
+ "saveError": "No se pudo guardar.",
+ "confirmClear": "¿Eliminar realmente la clave API?"
+ },
"interests": {
"title": "Intereses",
"new": "Nuevo interés",
diff --git a/frontend/src/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json
index f7f71cc..d52b92f 100644
--- a/frontend/src/i18n/locales/es/socialnetwork.json
+++ b/frontend/src/i18n/locales/es/socialnetwork.json
@@ -367,9 +367,16 @@
"learn": "Aprender",
"exercises": "Prueba del capítulo",
"learnVocabulary": "Aprender vocabulario",
+ "lessonOverviewText": "Esta lección combina vocabulario, patrones, pequeñas explicaciones gramaticales y práctica activa.",
"lessonDescription": "Descripción de la lección",
"culturalNotes": "Notas culturales",
"grammarExplanations": "Explicaciones gramaticales",
+ "grammarImpulse": "Impulso gramatical",
+ "learningGoals": "Objetivos",
+ "corePatterns": "Patrones básicos",
+ "speakingTasks": "Tareas orales",
+ "speakingPrompt": "Tarea oral",
+ "practicalTasks": "Tareas prácticas",
"importantVocab": "Términos importantes",
"vocabInfoText": "Estos términos se usarán en la prueba. Apréndelos aquí antes de pasar a la prueba del capítulo.",
"noVocabInfo": "Lee la descripción de arriba y las explicaciones de la prueba para aprender los términos más importantes.",
@@ -390,12 +397,31 @@
"goToNextLesson": "¿Pasar a la siguiente lección?",
"allLessonsCompleted": "¡Todas las lecciones completadas!",
"startExercises": "Ir a la prueba del capítulo",
+ "lessonTypeLabel": "Tipo de lección",
+ "recommendedDuration": "Duración recomendada",
+ "exerciseLoad": "Carga de ejercicios",
+ "exercisesShort": "ejercicios",
+ "durationFlexible": "Flexible",
+ "durationMinutes": "{minutes} minutos",
+ "lessonTypeVocab": "Vocabulario",
+ "lessonTypeGrammar": "Gramática",
+ "lessonTypeConversation": "Conversación",
+ "lessonTypeCulture": "Cultura",
+ "lessonTypeReview": "Repaso",
"correctAnswer": "Respuesta correcta",
"alternatives": "Respuestas alternativas",
"notStarted": "No empezado",
"continueCurrentLesson": "Continuar lección actual",
"previousLessonRequired": "Primero completa la lección anterior",
"lessonNumberShort": "#",
+ "buildSentencePlaceholder": "Construye aquí tu frase",
+ "completeDialogPlaceholder": "Completa la línea que falta en el diálogo",
+ "situationalResponsePlaceholder": "Formula tu respuesta a la situación",
+ "patternDrillPlaceholder": "Crea una frase adecuada con el patrón",
+ "modelSentence": "Frase modelo",
+ "modelDialogLine": "Línea posible del diálogo",
+ "modelResponse": "Respuesta posible",
+ "patternPrompt": "Patrón",
"readingAloudInstruction": "Lee el texto en voz alta. Haz clic en 'Iniciar grabación' y comienza a hablar.",
"speakingFromMemoryInstruction": "Habla de memoria. Usa las palabras clave mostradas.",
"startRecording": "Iniciar grabación",
diff --git a/frontend/src/router/settingsRoutes.js b/frontend/src/router/settingsRoutes.js
index 8cc8a2b..84640ba 100644
--- a/frontend/src/router/settingsRoutes.js
+++ b/frontend/src/router/settingsRoutes.js
@@ -4,6 +4,7 @@ const FlirtSettingsView = () => import('../views/settings/FlirtView.vue');
const SexualitySettingsView = () => import('../views/settings/SexualityView.vue');
const AccountSettingsView = () => import('../views/settings/AccountView.vue');
const InterestsView = () => import('../views/settings/InterestsView.vue');
+const LanguageAssistantView = () => import('../views/settings/LanguageAssistantView.vue');
const settingsRoutes = [
{
@@ -42,6 +43,12 @@ const settingsRoutes = [
component: InterestsView,
meta: { requiresAuth: true }
},
+ {
+ path: '/settings/language-assistant',
+ name: 'Language assistant settings',
+ component: LanguageAssistantView,
+ meta: { requiresAuth: true }
+ },
];
export default settingsRoutes;
diff --git a/frontend/src/views/settings/LanguageAssistantView.vue b/frontend/src/views/settings/LanguageAssistantView.vue
new file mode 100644
index 0000000..4845cf4
--- /dev/null
+++ b/frontend/src/views/settings/LanguageAssistantView.vue
@@ -0,0 +1,224 @@
+
+ {{ $t('settings.languageAssistant.intro') }} {{ loadError }}{{ $t('settings.languageAssistant.title') }}
+
{{ lesson.description }}
++ {{ $t('socialnetwork.vocab.courses.lessonOverviewText') }} +
+{{ lesson.culturalNotes }}
-{{ lesson.description }}
+{{ explanation.text }}
+{{ explanation.text }}
+{{ explanation.example }}
+{{ prompt.prompt }}
+{{ prompt.cue }}
+{{ task.text }}
+{{ lesson.culturalNotes }}
{{ getQuestionText(exercise) }}
++ {{ $t('socialnetwork.vocab.courses.modelSentence') }}: {{ exerciseResults[exercise.id].correctAnswer }} +
+{{ exerciseResults[exercise.id].explanation }}
+{{ getQuestionText(exercise) }}
+{{ line }}
++ {{ $t('socialnetwork.vocab.courses.modelDialogLine') }}: {{ exerciseResults[exercise.id].correctAnswer }} +
+{{ exerciseResults[exercise.id].explanation }}
+{{ getQuestionText(exercise) }}
+ + ++ {{ $t('socialnetwork.vocab.courses.modelResponse') }}: {{ exerciseResults[exercise.id].correctAnswer }} +
++ {{ $t('socialnetwork.vocab.courses.keywords') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }} +
+{{ exerciseResults[exercise.id].explanation }}
+{{ getQuestionText(exercise) }}
++ {{ $t('socialnetwork.vocab.courses.patternPrompt') }}: {{ getQuestionData(exercise).pattern }} +
+ + ++ {{ $t('socialnetwork.vocab.courses.modelPattern') }}: {{ exerciseResults[exercise.id].correctAnswer }} +
+{{ exerciseResults[exercise.id].explanation }}
+{{ getQuestionText(exercise) }}
@@ -543,6 +685,15 @@ export default { console.error('Fehler in importantVocab computed property:', e); return []; } + }, + lessonDidactics() { + return this.lesson?.didactics || { + learningGoals: [], + corePatterns: [], + grammarFocus: [], + speakingPrompts: [], + practicalTasks: [] + }; } }, watch: { @@ -787,10 +938,31 @@ export default { 5: 'conjugation', 6: 'declension', 7: 'reading_aloud', - 8: 'speaking_from_memory' + 8: 'speaking_from_memory', + 9: 'dialog_completion', + 10: 'situational_response', + 11: 'pattern_drill' }; return typeMap[exercise.exerciseTypeId] || 'unknown'; }, + getLessonTypeLabel(lessonType) { + const labels = { + vocab: this.$t('socialnetwork.vocab.courses.lessonTypeVocab'), + grammar: this.$t('socialnetwork.vocab.courses.lessonTypeGrammar'), + conversation: this.$t('socialnetwork.vocab.courses.lessonTypeConversation'), + culture: this.$t('socialnetwork.vocab.courses.lessonTypeCulture'), + review: this.$t('socialnetwork.vocab.courses.lessonTypeReview'), + vocab_review: this.$t('socialnetwork.vocab.courses.lessonTypeReview') + }; + return labels[lessonType] || lessonType || this.$t('socialnetwork.vocab.courses.lessonTypeVocab'); + }, + formatTargetMinutes(targetMinutes) { + const minutes = Number(targetMinutes); + if (!minutes) { + return this.$t('socialnetwork.vocab.courses.durationFlexible'); + } + return this.$t('socialnetwork.vocab.courses.durationMinutes', { minutes }); + }, getQuestionData(exercise) { if (!exercise.questionData) return null; return typeof exercise.questionData === 'string' @@ -861,9 +1033,11 @@ export default { } else if (exerciseType === 'multiple_choice') { // Multiple Choice: Index als Zahl answer = Number(answer); - } else if (exerciseType === 'transformation') { + } else if (exerciseType === 'transformation' || exerciseType === 'sentence_building' || exerciseType === 'dialog_completion' || exerciseType === 'pattern_drill') { // Transformation: String answer = String(answer || '').trim(); + } else if (exerciseType === 'situational_response') { + answer = String(answer || '').trim(); } else if (exerciseType === 'reading_aloud' || exerciseType === 'speaking_from_memory') { // Reading Aloud / Speaking From Memory: Verwende erkannten Text answer = this.recognizedText[exerciseId] || String(answer || '').trim(); @@ -1453,6 +1627,126 @@ export default { margin-bottom: 20px; } +.lesson-overview-card { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 20px; + margin-bottom: 20px; + background: linear-gradient(135deg, #fff8eb 0%, #f7efe2 100%); + border: 1px solid rgba(160, 120, 40, 0.18); + border-radius: 12px; +} + +.lesson-overview-text { + margin: 8px 0 0; + color: #5b4b2f; +} + +.lesson-meta-grid { + display: grid; + grid-template-columns: repeat(3, minmax(130px, 1fr)); + gap: 12px; + min-width: 360px; +} + +.lesson-meta-item { + padding: 12px 14px; + background: rgba(255, 255, 255, 0.72); + border-radius: 10px; +} + +.lesson-meta-label { + display: block; + margin-bottom: 6px; + font-size: 0.82rem; + color: #7a6848; +} + +.learn-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 18px; +} + +.didactic-card, +.lesson-description-box, +.cultural-notes { + padding: 18px; + background: #f8f9fa; + border-radius: 12px; + border: 1px solid #e7e7e7; +} + +.didactic-list { + margin: 0; + padding-left: 20px; +} + +.didactic-list li + li { + margin-top: 8px; +} + +.pattern-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.pattern-item { + padding: 12px 14px; + border-left: 4px solid #d2831f; + background: #fff; + border-radius: 8px; +} + +.grammar-example, +.speaking-cue, +.pattern-drill-hint { + margin-top: 8px; + color: #66553a; + font-style: italic; +} + +.speaking-prompt-item + .speaking-prompt-item, +.practical-task-item + .practical-task-item { + margin-top: 14px; +} + +.token-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 14px 0; +} + +.token-chip { + display: inline-flex; + align-items: center; + padding: 6px 12px; + background: #eef3ff; + border: 1px solid #cfdbff; + border-radius: 999px; + font-size: 0.95rem; +} + +.dialog-snippet { + margin: 14px 0; + padding: 14px; + background: #fff; + border-radius: 10px; + border: 1px solid #e6e6e6; +} + +.response-textarea { + width: 100%; + min-height: 120px; + padding: 12px; + border: 1px solid #d0d0d0; + border-radius: 8px; + resize: vertical; +} + .btn-back { padding: 8px 16px; border: 1px solid #ddd; @@ -2023,7 +2317,11 @@ export default { /* Reading Aloud & Speaking From Memory Styles */ .reading-aloud-exercise, -.speaking-from-memory-exercise { +.speaking-from-memory-exercise, +.sentence-building-exercise, +.dialog-completion-exercise, +.situational-response-exercise, +.pattern-drill-exercise { padding: 20px; background: #f8f9fa; border-radius: 8px; @@ -2229,4 +2527,15 @@ export default { .dialog-button:hover { background: #0056b3; } + +@media (max-width: 900px) { + .lesson-overview-card { + flex-direction: column; + } + + .lesson-meta-grid { + grid-template-columns: 1fr; + min-width: 0; + } +} diff --git a/mcp/language-course-server/.env.example b/mcp/language-course-server/.env.example new file mode 100644 index 0000000..677fa77 --- /dev/null +++ b/mcp/language-course-server/.env.example @@ -0,0 +1,22 @@ +# Kopiere nach .env und trage echte Werte ein (.env nicht committen). + +# Pflicht für LLM-Tools (ein Key reicht) +LANGUAGE_COURSE_LLM_API_KEY= +# Alternativ: +# OPENAI_API_KEY= + +# Optional: anderes Modell +# LANGUAGE_COURSE_LLM_MODEL=gpt-4o-mini + +# Optional: OpenAI-kompatibler Endpunkt +# OpenAI-Default wird verwendet, wenn leer. +# LANGUAGE_COURSE_LLM_BASE_URL=https://api.openai.com/v1 + +# Lokal z. B. Ollama (OpenAI-kompatibel): +# LANGUAGE_COURSE_LLM_BASE_URL=http://127.0.0.1:11434/v1 +# LANGUAGE_COURSE_LLM_MODEL=llama3.2 +# Ollama erwartet oft einen Dummy-Key: +# LANGUAGE_COURSE_LLM_API_KEY=ollama + +# Glossar-Daten (optional) +# LANGUAGE_COURSE_MCP_DATA=/absoluter/pfad/zu/data diff --git a/mcp/language-course-server/README.md b/mcp/language-course-server/README.md new file mode 100644 index 0000000..6181e50 --- /dev/null +++ b/mcp/language-course-server/README.md @@ -0,0 +1,85 @@ +# Language Course MCP Server + +Model-Context-Protocol-Server zum **Ergänzen und Abfragen** von Sprachkurs-Inhalten: Glossar (Begriffe/Phrasen), neue Einträge speichern, **Konversationsübungen** als Rollenspiel-Prompts. + +Liegt bewusst **neben** der YourPart-App (`mcp/language-course-server/`); Daten sind zunächst eine lokale JSON-Datei. Du kannst Inhalte später in `backend/scripts/create-language-courses.js` / Vocab-Modelle oder per API spiegeln. + +## Tools + +| Tool | Zweck | +|------|--------| +| `search_terms` | Glossar durchsuchen (`query`, optional `targetLang`, `nativeLang`) | +| `add_phrase` | Neue Phrase in `glossary.json` schreiben | +| `conversation_practice` | Zufälliges Szenario zum Üben (optional `topicHint`) | +| `export_glossary_snippet` | Letzte `n` Einträge als JSON für Export | +| `llm_status` | Prüft, ob API-Key/Modell gesetzt sind (ohne Netz) | +| `llm_chat` | Freier Chat mit dem LLM (`userMessage`, optional `systemPrompt`) | +| `llm_language_tutor` | `mode: dialogue` (Rollenspiel in Zielsprache) oder `evaluate` (Feedback zur Antwort) | + +## LLM-Anbindung (OpenAI-kompatibel) + +Voraussetzung für `llm_*`: ein **API-Key** und optional **Base-URL** / **Modell**. + +| Variable | Bedeutung | +|----------|-----------| +| `LANGUAGE_COURSE_LLM_API_KEY` oder `OPENAI_API_KEY` | Pflicht für LLM-Tools | +| `LANGUAGE_COURSE_LLM_BASE_URL` | Leer = OpenAI-Default; z. B. `http://127.0.0.1:11434/v1` (Ollama) | +| `LANGUAGE_COURSE_LLM_MODEL` | Default: `gpt-4o-mini` | + +Lokal: `cp .env.example .env` und Key eintragen. Beim Start wird `mcp/language-course-server/.env` geladen (sofern vorhanden). + +**Cursor:** dieselben Variablen im `env`-Block des MCP-Eintrags setzen (oder nur Pfad zur `.env` vermeiden und Keys dort nicht duplizieren – dann Server per Wrapper starten, der `dotenv` lädt; unser Server lädt `.env` automatisch aus seinem Ordner). + +**Ollama-Beispiel:** + +```bash +LANGUAGE_COURSE_LLM_BASE_URL=http://127.0.0.1:11434/v1 +LANGUAGE_COURSE_LLM_MODEL=llama3.2 +LANGUAGE_COURSE_LLM_API_KEY=ollama +``` + +## Daten + +- Standard: `data/glossary.json` (wird bei Bedarf angelegt). +- Alternativ: Umgebungsvariable `LANGUAGE_COURSE_MCP_DATA` auf ein Verzeichnis setzen (dort liegt `glossary.json`). + +## Installation + +```bash +cd mcp/language-course-server && npm install +``` + +## Cursor einbinden + +In den Cursor-Einstellungen unter **MCP** einen Server hinzufügen, z. B.: + +```json +{ + "mcpServers": { + "language-course": { + "command": "node", + "args": ["/ABSOLUTER/PFAD/zu/YourPart3/mcp/language-course-server/src/server.mjs"], + "env": { + "LANGUAGE_COURSE_MCP_DATA": "/ABSOLUTER/PFAD/zu/YourPart3/mcp/language-course-server/data", + "OPENAI_API_KEY": "sk-…" + } + } + } +} +``` + +`args` und `LANGUAGE_COURSE_MCP_DATA` an deinen Rechner anpassen. + +## Manuell testen + +```bash +cd mcp/language-course-server && npm run inspector +``` + +Öffnet den MCP Inspector; dort Tools ausprobieren. + +## Hinweis zur App + +Die Sprachkurse in der App nutzen `VocabCourse` / `vocabService` (siehe `backend/`). Dieser MCP ist eine **autororientierte Ergänzung** für deinen Workflow in der IDE. + +**YourPart-Web:** Unter **Einstellungen → Sprachassistent** (`/settings/language-assistant`) speichern Nutzer **API-Key** und Metadaten in **`community.user_param`** (Typen `llm_settings` / `llm_api_key`, Gruppe `languageAssistant` in `type.settings`) — verschlüsselt wie andere Profilparameter. Der lokale MCP nutzt weiterhin die Umgebungsvariablen hier; beides ist getrennt, bis ein Backend-Proxy die gespeicherten Werte nutzt. diff --git a/mcp/language-course-server/data/glossary.example.json b/mcp/language-course-server/data/glossary.example.json new file mode 100644 index 0000000..87df2a5 --- /dev/null +++ b/mcp/language-course-server/data/glossary.example.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "entries": [ + { + "id": "ex-1", + "targetLang": "de", + "nativeLang": "en", + "term": "Guten Tag", + "translation": "Good day", + "context": "formal greeting", + "tags": ["greeting", "A1"] + } + ] +} diff --git a/mcp/language-course-server/data/glossary.json b/mcp/language-course-server/data/glossary.json new file mode 100644 index 0000000..7f19696 --- /dev/null +++ b/mcp/language-course-server/data/glossary.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "entries": [] +} diff --git a/mcp/language-course-server/package.json b/mcp/language-course-server/package.json new file mode 100644 index 0000000..6ac1309 --- /dev/null +++ b/mcp/language-course-server/package.json @@ -0,0 +1,23 @@ +{ + "name": "language-course-mcp-server", + "version": "1.0.0", + "description": "MCP-Server: Sprachkurs – Begriffe/Phrasen, Ergänzungen, Konversationsübung", + "type": "module", + "private": true, + "bin": { + "language-course-mcp": "./src/server.mjs" + }, + "scripts": { + "start": "node src/server.mjs", + "inspect": "npx @modelcontextprotocol/inspector node src/server.mjs" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "dotenv": "^16.4.5", + "openai": "^4.77.0", + "zod": "^3.24.0" + } +} diff --git a/mcp/language-course-server/src/llm.mjs b/mcp/language-course-server/src/llm.mjs new file mode 100644 index 0000000..abdf8fd --- /dev/null +++ b/mcp/language-course-server/src/llm.mjs @@ -0,0 +1,58 @@ +/** + * OpenAI-kompatible Chat-API (OpenAI, Azure OpenAI, Ollama, LM Studio, vLLM, …). + * + * Umgebung: + * LANGUAGE_COURSE_LLM_API_KEY oder OPENAI_API_KEY + * LANGUAGE_COURSE_LLM_BASE_URL (optional, z. B. http://127.0.0.1:11434/v1 für Ollama) + * LANGUAGE_COURSE_LLM_MODEL (optional, Default: gpt-4o-mini) + */ +import OpenAI from 'openai'; + +export function getLlmEnv() { + const apiKey = + process.env.LANGUAGE_COURSE_LLM_API_KEY || + process.env.OPENAI_API_KEY || + ''; + const baseURL = process.env.LANGUAGE_COURSE_LLM_BASE_URL || undefined; + const model = process.env.LANGUAGE_COURSE_LLM_MODEL || 'gpt-4o-mini'; + return { apiKey, baseURL, model }; +} + +export function isLlmConfigured() { + return Boolean(getLlmEnv().apiKey); +} + +export function getOpenAiClient() { + const { apiKey, baseURL } = getLlmEnv(); + if (!apiKey) return null; + return new OpenAI({ + apiKey, + baseURL, + }); +} + +/** + * @param {import('openai').ChatCompletionMessageParam[]} messages + * @param {{ model?: string; temperature?: number; maxTokens?: number }} [opts] + */ +export async function chatComplete(messages, opts = {}) { + const client = getOpenAiClient(); + if (!client) { + throw new Error( + 'Kein LLM konfiguriert: LANGUAGE_COURSE_LLM_API_KEY oder OPENAI_API_KEY setzen (siehe README).' + ); + } + const { model, temperature = 0.6, maxTokens = 2048 } = opts; + const m = model || getLlmEnv().model; + const res = await client.chat.completions.create({ + model: m, + messages, + temperature, + max_tokens: maxTokens, + }); + const text = res.choices[0]?.message?.content?.trim() || ''; + if (!text) { + throw new Error('Leere Antwort vom Modell.'); + } + return text; +} diff --git a/mcp/language-course-server/src/server.mjs b/mcp/language-course-server/src/server.mjs new file mode 100644 index 0000000..a85e487 --- /dev/null +++ b/mcp/language-course-server/src/server.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +/** + * MCP-Server: Sprachkurs-Ergänzung (Begriffe, Phrasen, Konversationsübung). + * + * Daten: JSON unter LANGUAGE_COURSE_MCP_DATA (Standard: ../data), Datei glossary.json + * LLM: LANGUAGE_COURSE_LLM_* / OPENAI_API_KEY (siehe README, .env.example) + */ +import { config as loadDotenv } from 'dotenv'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import crypto from 'crypto'; +import { chatComplete, getLlmEnv, isLlmConfigured } from './llm.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +loadDotenv({ path: path.join(ROOT, '.env') }); +const DATA_DIR = process.env.LANGUAGE_COURSE_MCP_DATA + ? path.resolve(process.env.LANGUAGE_COURSE_MCP_DATA) + : path.join(ROOT, 'data'); +const GLOSSARY_FILE = path.join(DATA_DIR, 'glossary.json'); + +function ensureDataDir() { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function loadGlossary() { + ensureDataDir(); + if (!fs.existsSync(GLOSSARY_FILE)) { + const empty = { version: 1, entries: [] }; + fs.writeFileSync(GLOSSARY_FILE, JSON.stringify(empty, null, 2), 'utf8'); + return empty; + } + const raw = fs.readFileSync(GLOSSARY_FILE, 'utf8'); + return JSON.parse(raw); +} + +function saveGlossary(data) { + ensureDataDir(); + fs.writeFileSync(GLOSSARY_FILE, JSON.stringify(data, null, 2), 'utf8'); +} + +const server = new McpServer({ + name: 'language-course', + version: '1.0.0', +}); + +server.tool( + 'search_terms', + 'Begriffe und Phrasen im Glossar durchsuchen (Teilstring in term, translation, context, tags).', + { + query: z.string().describe('Suchtext (leer = alles)'), + targetLang: z.string().optional().describe('ISO/Sprachname filter, z. B. de'), + nativeLang: z.string().optional().describe('Filter Muttersprache, z. B. en'), + limit: z.number().int().min(1).max(100).optional().default(20), + }, + async ({ query, targetLang, nativeLang, limit }) => { + const g = loadGlossary(); + const q = (query || '').trim().toLowerCase(); + let list = g.entries || []; + if (targetLang) { + list = list.filter((e) => (e.targetLang || '').toLowerCase() === targetLang.toLowerCase()); + } + if (nativeLang) { + list = list.filter((e) => (e.nativeLang || '').toLowerCase() === nativeLang.toLowerCase()); + } + if (q) { + list = list.filter((e) => { + const hay = [ + e.term, + e.translation, + e.context, + ...(Array.isArray(e.tags) ? e.tags : []), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return hay.includes(q); + }); + } + list = list.slice(0, limit); + const text = list.length + ? JSON.stringify(list, null, 2) + : 'Keine Treffer.'; + return { content: [{ type: 'text', text }] }; + } +); + +server.tool( + 'add_phrase', + 'Neue Phrase oder Begriff zum Glossar hinzufügen (schreibt glossary.json).', + { + targetLang: z.string().describe('Zielsprache, z. B. de'), + term: z.string().describe('Ausdruck in der Zielsprache'), + translation: z.string().describe('Übersetzung oder Erklärung'), + nativeLang: z.string().optional().describe('Muttersprache/Lernkontext, z. B. en'), + context: z.string().optional().describe('Kontext oder Beispielsatz'), + tags: z.array(z.string()).optional().describe('Stichwörter, z. B. ["travel","A2"]'), + }, + async ({ targetLang, term, translation, nativeLang, context, tags }) => { + const g = loadGlossary(); + const id = crypto.randomUUID(); + g.entries = g.entries || []; + g.entries.push({ + id, + targetLang, + nativeLang: nativeLang || null, + term: term.trim(), + translation: translation.trim(), + context: context || null, + tags: tags || [], + }); + saveGlossary(g); + return { + content: [ + { + type: 'text', + text: `Eintrag gespeichert (id=${id}). Datei: ${GLOSSARY_FILE}`, + }, + ], + }; + } +); + +const SCENARIOS = [ + { + topic: 'Bahnhof / Tickets', + prompt: + 'Du bist am Schalter. Frage höflich nach einer Fahrkarte nach … (Ziel nennen). Reagiere auf Rückfragen.', + }, + { + topic: 'Restaurant', + prompt: + 'Bestelle ein Gericht, frage nach Allergenen, bitte um die Rechnung. Halte den Dialog kurz und natürlich.', + }, + { + topic: 'Arzt – Termin', + prompt: + 'Rufe an oder sprich am Empfang: Du brauchst einen Termin und beschreibst kurz dein Anliegen.', + }, + { + topic: 'Smalltalk Wetter', + prompt: + 'Führe ein 1-minütiges Gespräch über das Wetter und das Wochenende – keine Fakten nötig, nur flüssig bleiben.', + }, +]; + +server.tool( + 'conversation_practice', + 'Liefert ein kurzes Rollenspiel-Szenario zum Üben (ohne Bewertung). Optional mit Fokus-Thema.', + { + topicHint: z.string().optional().describe('z. B. Restaurant, Arzt, Bahnhof'), + targetLang: z.string().optional().describe('Nur für die Anzeige, z. B. Deutsch'), + }, + async ({ topicHint, targetLang }) => { + let pick = SCENARIOS[Math.floor(Math.random() * SCENARIOS.length)]; + if (topicHint) { + const h = topicHint.toLowerCase(); + const found = SCENARIOS.find((s) => s.topic.toLowerCase().includes(h) || h.includes(s.topic.split(' ')[0].toLowerCase())); + if (found) pick = found; + } + const lines = [ + `Zielsprache (Anzeige): ${targetLang || 'frei wählbar'}`, + `Thema: ${pick.topic}`, + '', + 'Aufgabe:', + pick.prompt, + '', + 'Tipp: Formuliere laut oder schriftlich 5–10 Äußerungen; danach kannst du mit search_terms passende Redewendungen nachschlagen.', + ]; + return { content: [{ type: 'text', text: lines.join('\n') }] }; + } +); + +server.tool( + 'export_glossary_snippet', + 'Kopier-fertigen JSON-Ausschnitt der letzten n Einträge exportieren (für Import ins YourPart-Skript o. Ä.).', + { limit: z.number().int().min(1).max(200).optional().default(30) }, + async ({ limit }) => { + const g = loadGlossary(); + const entries = (g.entries || []).slice(-limit); + const text = JSON.stringify({ version: g.version || 1, entries }, null, 2); + return { content: [{ type: 'text', text }] }; + } +); + +server.tool( + 'llm_status', + 'Prüft, ob ein LLM per API-Key und Modell erreichbar konfiguriert ist (kein Netzaufruf).', + {}, + async () => { + const { model, baseURL } = getLlmEnv(); + const ok = isLlmConfigured(); + const text = [ + `Konfiguriert: ${ok ? 'ja' : 'nein'}`, + `Modell (Default): ${model}`, + baseURL ? `Base URL: ${baseURL}` : 'Base URL: (OpenAI-Standard)', + '', + ok + ? 'Hinweis: Echte Erreichbarkeit erst mit llm_chat oder llm_language_tutor testen.' + : 'Setze LANGUAGE_COURSE_LLM_API_KEY oder OPENAI_API_KEY (siehe .env.example).', + ].join('\n'); + return { content: [{ type: 'text', text }] }; + } +); + +server.tool( + 'llm_chat', + 'Freier Chat mit dem konfigurierten LLM (OpenAI-kompatibel). Für Erklärungen, Übersetzungen, freie Dialoge.', + { + userMessage: z.string().describe('Nutzer-/Assistenten-Anfrage'), + systemPrompt: z + .string() + .optional() + .describe('Systemrolle, z. B. „Du bist ein freundlicher Deutschlehrer …“'), + temperature: z.number().min(0).max(2).optional().default(0.6), + }, + async ({ userMessage, systemPrompt, temperature }) => { + try { + const messages = []; + if (systemPrompt && systemPrompt.trim()) { + messages.push({ role: 'system', content: systemPrompt.trim() }); + } + messages.push({ role: 'user', content: userMessage }); + const out = await chatComplete(messages, { temperature }); + return { content: [{ type: 'text', text: out }] }; + } catch (e) { + return { + content: [{ type: 'text', text: e.message || String(e) }], + isError: true, + }; + } + } +); + +server.tool( + 'llm_language_tutor', + 'Rollenspiel oder Feedback: Das Modell antwortet in der Zielsprache (Dialog) oder gibt kurzes Feedback (evaluate).', + { + mode: z.enum(['dialogue', 'evaluate']).describe('dialogue = Konversation üben; evaluate = Antwort bewerten'), + targetLang: z.string().describe('Zielsprache, z. B. Deutsch'), + nativeLang: z.string().optional().describe('Erklärungen ggf. in dieser Sprache, z. B. Englisch'), + scenario: z.string().describe('Szenario oder Aufgabe (z. B. Restaurant, vorheriger conversation_practice-Text)'), + userMessage: z + .string() + .describe('Deine Äußerung in der Zielsprache (Dialog) bzw. deine Lösung (evaluate)'), + conversationContext: z + .string() + .optional() + .describe('Optional: bisheriger Dialog oder Lektionskontext'), + }, + async ({ mode, targetLang, nativeLang, scenario, userMessage, conversationContext }) => { + try { + const explain = nativeLang || 'die Muttersprache des Lernenden'; + let system; + if (mode === 'dialogue') { + system = [ + `Du bist eine Gesprächspartnerin/ein Gesprächspartner für ${targetLang}.`, + `Spiele die andere Rolle im folgenden Szenario realistisch und kurz (1–3 Sätze pro Antwort).`, + `Antworte durchgehend in ${targetLang}.`, + `Wenn der Lernende einen Fehler macht, korrigiere ihn nicht ausführlich im Dialog; höchstens eine sanfte, kurze Rückmeldung.`, + `Erklärungen nur auf ${explain}, und nur wenn der Lernende ausdrücklich nach Hilfe fragt.`, + '', + `Szenario: ${scenario}`, + conversationContext ? `\nBisheriger Kontext:\n${conversationContext}` : '', + ].join('\n'); + } else { + system = [ + `Du bist eine freundliche Sprachlehrerin für ${targetLang}.`, + `Der Lernende hat eine Aufgabe zum Szenario bearbeitet. Bewerte kurz (Stärken, 1–2 Verbesserungen).`, + `Antworte mit kurzem Feedback zuerst in ${explain}; optional ein korrektes Beispiel in ${targetLang}.`, + `Halte dich unter etwa 120 Wörtern.`, + '', + `Szenario/Aufgabe: ${scenario}`, + conversationContext ? `\nZusatzkontext:\n${conversationContext}` : '', + ].join('\n'); + } + const user = userMessage.trim(); + const out = await chatComplete( + [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + { temperature: mode === 'dialogue' ? 0.75 : 0.4 } + ); + return { content: [{ type: 'text', text: out }] }; + } catch (e) { + return { + content: [{ type: 'text', text: e.message || String(e) }], + isError: true, + }; + } + } +); + +const transport = new StdioServerTransport(); +await server.connect(transport);