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 @@ + + + + + diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index d724ecd..ec6ebc8 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -30,26 +30,80 @@
-

{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}

- - -
-

{{ $t('socialnetwork.vocab.courses.lessonDescription') }}

-

{{ lesson.description }}

+
+
+

{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}

+

+ {{ $t('socialnetwork.vocab.courses.lessonOverviewText') }} +

+
+
+
+ {{ $t('socialnetwork.vocab.courses.lessonTypeLabel') }} + {{ getLessonTypeLabel(lesson.lessonType) }} +
+
+ {{ $t('socialnetwork.vocab.courses.recommendedDuration') }} + {{ formatTargetMinutes(lesson.targetMinutes) }} +
+
+ {{ $t('socialnetwork.vocab.courses.exerciseLoad') }} + {{ effectiveExercises?.length || 0 }} {{ $t('socialnetwork.vocab.courses.exercisesShort') }} +
+
- -
-

{{ $t('socialnetwork.vocab.courses.culturalNotes') }}

-

{{ lesson.culturalNotes }}

-
+
+
+

{{ $t('socialnetwork.vocab.courses.lessonDescription') }}

+

{{ lesson.description }}

+
- -
-

{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}

-
- {{ explanation.title }} -

{{ explanation.text }}

+
+

{{ $t('socialnetwork.vocab.courses.learningGoals') }}

+
    +
  • {{ goal }}
  • +
+
+ +
+

{{ $t('socialnetwork.vocab.courses.corePatterns') }}

+
+
+ {{ pattern }} +
+
+
+ +
+

{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}

+
+ {{ explanation.title || $t('socialnetwork.vocab.courses.grammarImpulse') }} +

{{ explanation.text }}

+

{{ explanation.example }}

+
+
+ +
+

{{ $t('socialnetwork.vocab.courses.speakingTasks') }}

+
+ {{ prompt.title || $t('socialnetwork.vocab.courses.speakingPrompt') }} +

{{ prompt.prompt }}

+

{{ prompt.cue }}

+
+
+ +
+

{{ $t('socialnetwork.vocab.courses.practicalTasks') }}

+
+ {{ task.title }} +

{{ task.text }}

+
+
+ +
+

{{ $t('socialnetwork.vocab.courses.culturalNotes') }}

+

{{ lesson.culturalNotes }}

@@ -253,6 +307,94 @@
+
+

{{ getQuestionText(exercise) }}

+
+ {{ token }} +
+ + +
+ {{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }} +

+ {{ $t('socialnetwork.vocab.courses.modelSentence') }}: {{ exerciseResults[exercise.id].correctAnswer }} +

+

{{ exerciseResults[exercise.id].explanation }}

+
+
+ +
+

{{ getQuestionText(exercise) }}

+
+

{{ line }}

+
+ + +
+ {{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }} +

+ {{ $t('socialnetwork.vocab.courses.modelDialogLine') }}: {{ exerciseResults[exercise.id].correctAnswer }} +

+

{{ exerciseResults[exercise.id].explanation }}

+
+
+ +
+

{{ getQuestionText(exercise) }}

+