Add language assistant settings and related features: Introduce new routes and controller methods for managing language assistant settings, including retrieval and saving of LLM configurations. Update navigation structure to include language assistant options. Enhance vocab course model to support additional learning attributes such as learning goals and core patterns. Update SQL scripts to reflect new database schema changes for vocab courses. Improve localization for language assistant settings in German and English.

This commit is contained in:
Torsten Schulz (local)
2026-03-25 15:53:49 +01:00
parent 8af726c65a
commit d50d3c4016
40 changed files with 3145 additions and 56 deletions

View File

@@ -214,6 +214,10 @@ const menuStructure = {
visible: ["all"],
path: "/settings/account"
},
languageAssistant: {
visible: ["all"],
path: "/settings/language-assistant"
},
personal: {
visible: ["all"],
path: "/settings/personal"

View File

@@ -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;

View File

@@ -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;
`);
}
};

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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();

View File

@@ -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 };
}
}

View File

@@ -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');

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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)) {