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"], visible: ["all"],
path: "/settings/account" path: "/settings/account"
}, },
languageAssistant: {
visible: ["all"],
path: "/settings/language-assistant"
},
personal: { personal: {
visible: ["all"], visible: ["all"],
path: "/settings/personal" path: "/settings/personal"

View File

@@ -185,6 +185,38 @@ class SettingsController {
res.status(500).json({ error: 'Internal server error' }); 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; 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, allowNull: true,
field: 'cultural_notes' 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: { targetMinutes: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, 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('/removeinterest/:id', authenticate, settingsController.removeInterest.bind(settingsController));
router.get('/visibilities', authenticate, settingsController.getVisibilities.bind(settingsController)); router.get('/visibilities', authenticate, settingsController.getVisibilities.bind(settingsController));
router.post('/update-visibility', authenticate, settingsController.updateVisibility.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; export default router;

View File

@@ -19,6 +19,19 @@ const LESSONS_TO_ADD = [
title: 'Woche 1 - Wiederholung', title: 'Woche 1 - Wiederholung',
description: 'Wiederhole alle Inhalte der ersten Woche', description: 'Wiederhole alle Inhalte der ersten Woche',
culturalNotes: 'Wiederholung ist der Schlüssel zum Erfolg!', 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, targetMinutes: 30,
targetScorePercent: 80, targetScorePercent: 80,
requiresReview: false requiresReview: false
@@ -31,6 +44,12 @@ const LESSONS_TO_ADD = [
title: 'Woche 1 - Vokabeltest', title: 'Woche 1 - Vokabeltest',
description: 'Teste dein Wissen aus Woche 1', description: 'Teste dein Wissen aus Woche 1',
culturalNotes: null, 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, targetMinutes: 15,
targetScorePercent: 80, targetScorePercent: 80,
requiresReview: true requiresReview: true
@@ -89,6 +108,9 @@ async function addBisayaWeek1Lessons() {
dayNumber: lessonData.dayNumber, dayNumber: lessonData.dayNumber,
lessonType: lessonData.lessonType, lessonType: lessonData.lessonType,
culturalNotes: lessonData.culturalNotes, culturalNotes: lessonData.culturalNotes,
learningGoals: lessonData.learningGoals || [],
corePatterns: lessonData.corePatterns || [],
speakingPrompts: lessonData.speakingPrompts || [],
targetMinutes: lessonData.targetMinutes, targetMinutes: lessonData.targetMinutes,
targetScorePercent: lessonData.targetScorePercent, targetScorePercent: lessonData.targetScorePercent,
requiresReview: lessonData.requiresReview 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 VocabCourse from '../models/community/vocab_course.js';
import User from '../models/community/user.js'; import User from '../models/community/user.js';
function withTypeName(exerciseTypeName, exercise) {
return {
...exercise,
exerciseTypeName
};
}
// Bisaya-spezifische Übungen basierend auf Lektionsthemen // Bisaya-spezifische Übungen basierend auf Lektionsthemen
const BISAYA_EXERCISES = { const BISAYA_EXERCISES = {
// Lektion 1: Begrüßungen & Höflichkeit // Lektion 1: Begrüßungen & Höflichkeit
@@ -62,6 +69,35 @@ const BISAYA_EXERCISES = {
correctAnswer: 0 correctAnswer: 0
}, },
explanation: '"Salamat" bedeutet "Danke" auf Bisaya.' 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'] alternatives: ['Mama', 'Nanay', 'Inahan']
}, },
explanation: '"Nanay" oder "Mama" bedeutet "Mutter" auf Bisaya.' 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) // Lektion: Haus & Familie (Balay, Kwarto, Kusina, Pamilya)
@@ -424,6 +545,34 @@ const BISAYA_EXERCISES = {
answers: ['Ni', 'Mo'] answers: ['Ni', 'Mo']
}, },
explanation: 'Ni- für Vergangenheit, Mo- für Zukunft.' 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 }, answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".' 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) // Woche 1 - Vokabeltest (Lektion 10)
@@ -1167,10 +1344,48 @@ const BISAYA_EXERCISES = {
}, },
answerData: { type: 'multiple_choice', correctAnswer: 0 }, answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".' 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() { async function findOrCreateSystemUser() {
let systemUser = await User.findOne({ let systemUser = await User.findOne({
where: { where: {
@@ -1270,10 +1485,14 @@ async function createBisayaCourseContent() {
const replacePlaceholders = [ const replacePlaceholders = [
'Woche 1 - Wiederholung', 'Woche 1 - Wiederholung',
'Woche 1 - Vokabeltest', 'Woche 1 - Vokabeltest',
'Begrüßungen & Höflichkeit',
'Familienwörter',
'Essen & Fürsorge',
'Alltagsgespräche - Teil 1', 'Alltagsgespräche - Teil 1',
'Alltagsgespräche - Teil 2', 'Alltagsgespräche - Teil 2',
'Haus & Familie', 'Haus & Familie',
'Ort & Richtung' 'Ort & Richtung',
'Zeitformen - Grundlagen'
].includes(lesson.title); ].includes(lesson.title);
const existingCount = await VocabGrammarExercise.count({ const existingCount = await VocabGrammarExercise.count({
where: { lessonId: lesson.id } where: { lessonId: lesson.id }
@@ -1292,9 +1511,10 @@ async function createBisayaCourseContent() {
// Erstelle Übungen // Erstelle Übungen
let exerciseNumber = 1; let exerciseNumber = 1;
for (const exerciseData of exercises) { for (const exerciseData of exercises) {
const exerciseTypeId = await resolveExerciseTypeId(exerciseData);
await VocabGrammarExercise.create({ await VocabGrammarExercise.create({
lessonId: lesson.id, lessonId: lesson.id,
exerciseTypeId: exerciseData.exerciseTypeId, exerciseTypeId,
exerciseNumber: exerciseNumber++, exerciseNumber: exerciseNumber++,
title: exerciseData.title, title: exerciseData.title,
instruction: exerciseData.instruction, 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 User from '../models/community/user.js';
import crypto from 'crypto'; 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 = [ const LESSONS = [
// WOCHE 1: Grundlagen & Aussprache // WOCHE 1: Grundlagen & Aussprache
{ week: 1, day: 1, num: 1, type: 'conversation', title: 'Begrüßungen & Höflichkeit', { 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, dayNumber: lessonData.day,
lessonType: lessonData.type, lessonType: lessonData.type,
culturalNotes: lessonData.cultural, 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, targetMinutes: lessonData.targetMin,
targetScorePercent: lessonData.targetScore, targetScorePercent: lessonData.targetScore,
requiresReview: lessonData.review 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 VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import User from '../models/community/user.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 LESSON_TITLES = ['Woche 1 - Wiederholung', 'Woche 1 - Vokabeltest'];
const BISAYA_EXERCISES = { 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: 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 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: 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': [ '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: 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: 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: 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: 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() { async function updateWeek1BisayaExercises() {
await sequelize.authenticate(); await sequelize.authenticate();
console.log('Datenbankverbindung erfolgreich hergestellt.\n'); console.log('Datenbankverbindung erfolgreich hergestellt.\n');
@@ -93,9 +123,10 @@ async function updateWeek1BisayaExercises() {
let exerciseNumber = 1; let exerciseNumber = 1;
for (const ex of exercises) { for (const ex of exercises) {
const exerciseTypeId = await resolveExerciseTypeId(ex);
await VocabGrammarExercise.create({ await VocabGrammarExercise.create({
lessonId: lesson.id, lessonId: lesson.id,
exerciseTypeId: ex.exerciseTypeId, exerciseTypeId,
exerciseNumber: exerciseNumber++, exerciseNumber: exerciseNumber++,
title: ex.title, title: ex.title,
instruction: ex.instruction, instruction: ex.instruction,

View File

@@ -10,7 +10,6 @@ import InterestTranslation from '../models/type/interest_translation.js';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import UserParamVisibilityType from '../models/type/user_param_visibility.js'; import UserParamVisibilityType from '../models/type/user_param_visibility.js';
import UserParamVisibility from '../models/community/user_param_visibility.js'; import UserParamVisibility from '../models/community/user_param_visibility.js';
import { generateIv } from '../utils/encryption.js';
class SettingsService extends BaseService{ class SettingsService extends BaseService{
async getUserParams(userId, paramDescriptions) { async getUserParams(userId, paramDescriptions) {
@@ -381,6 +380,129 @@ class SettingsService extends BaseService{
throw error; 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(); export default new SettingsService();

View File

@@ -29,6 +29,126 @@ export default class VocabService {
.replace(/\s+/g, ' '); .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) { async _getLanguageAccess(userId, languageId) {
const id = Number.parseInt(languageId, 10); const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
@@ -895,15 +1015,7 @@ export default class VocabService {
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || []; plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
} }
console.log(`[getLesson] Lektion ${lessonId} geladen:`, { plainLesson.didactics = this._buildLessonDidactics(plainLesson);
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
});
return plainLesson; return plainLesson;
} }
@@ -975,7 +1087,7 @@ export default class VocabService {
return exercises.map(e => e.get({ plain: true })); 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 user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId); const course = await VocabCourse.findByPk(courseId);
@@ -1019,6 +1131,11 @@ export default class VocabService {
lessonType: lessonType || 'vocab', lessonType: lessonType || 'vocab',
audioUrl: audioUrl || null, audioUrl: audioUrl || null,
culturalNotes: culturalNotes || 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, targetMinutes: targetMinutes ? Number(targetMinutes) : null,
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80, targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
@@ -1027,7 +1144,7 @@ export default class VocabService {
return lesson.get({ plain: true }); 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 user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, { const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }] include: [{ model: VocabCourse, as: 'course' }]
@@ -1054,6 +1171,11 @@ export default class VocabService {
if (lessonType !== undefined) updates.lessonType = lessonType; if (lessonType !== undefined) updates.lessonType = lessonType;
if (audioUrl !== undefined) updates.audioUrl = audioUrl; if (audioUrl !== undefined) updates.audioUrl = audioUrl;
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes; 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 (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent); if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview); if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
@@ -1450,6 +1572,15 @@ export default class VocabService {
correctAnswer = questionData.expectedText || questionData.text || ''; correctAnswer = questionData.expectedText || questionData.text || '';
alternatives = questionData.keywords || []; 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 // Fallback: Versuche correct oder correctAnswer
else { else {
correctAnswer = Array.isArray(answerData.correct) correctAnswer = Array.isArray(answerData.correct)
@@ -1531,10 +1662,9 @@ export default class VocabService {
// Für Reading Aloud: userAnswer ist der erkannte Text (String) // Für Reading Aloud: userAnswer ist der erkannte Text (String)
// Vergleiche mit dem erwarteten Text aus questionData.text // Vergleiche mit dem erwarteten Text aus questionData.text
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') { 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 expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
const normalizedExpected = normalize(expectedText); const normalizedExpected = this._normalizeTextAnswer(expectedText);
const normalizedUser = normalize(userAnswer); const normalizedUser = this._normalizeTextAnswer(userAnswer);
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz // Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
if (parsedQuestionData.type === 'reading_aloud') { if (parsedQuestionData.type === 'reading_aloud') {
@@ -1550,16 +1680,33 @@ export default class VocabService {
return normalizedUser === normalizedExpected; return normalizedUser === normalizedExpected;
} }
// Prüfe ob alle Schlüsselwörter vorhanden sind // 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) // 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 correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers]; const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
const normalizedUserAnswer = normalize(userAnswer); const normalizedUserAnswer = this._normalizeTextAnswer(userAnswer);
return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer); return correctAnswersArray.some(correct => this._normalizeTextAnswer(correct) === normalizedUserAnswer);
} }
async getGrammarExerciseProgress(hashedUserId, lessonId) { async getGrammarExerciseProgress(hashedUserId, lessonId) {
@@ -1638,5 +1785,3 @@ export default class VocabService {
return { success: true }; 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', lesson_type TEXT DEFAULT 'vocab',
audio_url TEXT, audio_url TEXT,
cultural_notes TEXT, cultural_notes TEXT,
learning_goals JSONB,
core_patterns JSONB,
grammar_focus JSONB,
speaking_prompts JSONB,
practical_tasks JSONB,
target_minutes INTEGER, target_minutes INTEGER,
target_score_percent INTEGER DEFAULT 80, target_score_percent INTEGER DEFAULT 80,
requires_review BOOLEAN DEFAULT false, requires_review BOOLEAN DEFAULT false,
@@ -219,7 +224,12 @@ INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
('sentence_building', 'Satzbau-Übung'), ('sentence_building', 'Satzbau-Übung'),
('transformation', 'Satzumformung'), ('transformation', 'Satzumformung'),
('conjugation', 'Konjugations-Übung'), ('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; 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'; 'Type: vocab, grammar, conversation, culture, review';
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
'Zielzeit in Minuten für diese Lektion'; '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 COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)'; 'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS 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 lesson_type TEXT DEFAULT 'vocab',
ADD COLUMN IF NOT EXISTS audio_url TEXT, ADD COLUMN IF NOT EXISTS audio_url TEXT,
ADD COLUMN IF NOT EXISTS cultural_notes 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_minutes INTEGER,
ADD COLUMN IF NOT EXISTS target_score_percent INTEGER DEFAULT 80, ADD COLUMN IF NOT EXISTS target_score_percent INTEGER DEFAULT 80,
ADD COLUMN IF NOT EXISTS requires_review BOOLEAN DEFAULT false; 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'), ('sentence_building', 'Satzbau-Übung'),
('transformation', 'Satzumformung'), ('transformation', 'Satzumformung'),
('conjugation', 'Konjugations-Übung'), ('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; 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'; 'Type: vocab, grammar, conversation, culture, review';
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
'Zielzeit in Minuten für diese Lektion'; '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 COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)'; 'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS

View File

@@ -17,6 +17,10 @@ const initializeSettings = async () => {
where: { name: 'flirt' }, where: { name: 'flirt' },
defaults: { name: 'flirt' } defaults: { name: 'flirt' }
}); });
await SettingsType.findOrCreate({
where: { name: 'languageAssistant' },
defaults: { name: 'languageAssistant' }
});
}; };
export default initializeSettings; export default initializeSettings;

View File

@@ -46,6 +46,8 @@ const initializeTypes = async () => {
willChildren: { type: 'bool', setting: 'flirt', minAge: 14 }, willChildren: { type: 'bool', setting: 'flirt', minAge: 14 },
smokes: { type: 'singleselect', setting: 'flirt', minAge: 14}, smokes: { type: 'singleselect', setting: 'flirt', minAge: 14},
drinks: { 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; let orderId = 1;
for (const key of Object.keys(userParams)) { for (const key of Object.keys(userParams)) {

View File

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

View File

@@ -65,6 +65,7 @@ const TITLE_MAP = {
'Sexuality settings': 'Sexualität', 'Sexuality settings': 'Sexualität',
'Flirt settings': 'Flirt', 'Flirt settings': 'Flirt',
'Account settings': 'Account', 'Account settings': 'Account',
'Language assistant settings': 'Sprachassistent',
Interests: 'Interessen', Interests: 'Interessen',
AdminInterests: 'Interessenverwaltung', AdminInterests: 'Interessenverwaltung',
AdminUsers: 'Benutzer', AdminUsers: 'Benutzer',

View File

@@ -61,7 +61,8 @@
"flirt": "Flirt", "flirt": "Flirt",
"interests": "Interessen", "interests": "Interessen",
"notifications": "Benachrichtigungen", "notifications": "Benachrichtigungen",
"sexuality": "Sexualität" "sexuality": "Sexualität",
"languageAssistant": "Sprachassistent"
}, },
"m-administration": { "m-administration": {
"contactrequests": "Kontaktanfragen", "contactrequests": "Kontaktanfragen",

View File

@@ -150,6 +150,27 @@
"changeaction": "Benutzerdaten ändern", "changeaction": "Benutzerdaten ändern",
"oldpassword": "Altes Passwort (benötigt)" "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": { "interests": {
"title": "Interessen", "title": "Interessen",
"new": "Neues Interesse", "new": "Neues Interesse",

View File

@@ -370,9 +370,16 @@
"learn": "Lernen", "learn": "Lernen",
"exercises": "Kapitel-Prüfung", "exercises": "Kapitel-Prüfung",
"learnVocabulary": "Vokabeln lernen", "learnVocabulary": "Vokabeln lernen",
"lessonOverviewText": "Diese Lektion verbindet Vokabeln, Muster, kurze Grammatikimpulse und aktive Sprachpraxis.",
"lessonDescription": "Lektions-Beschreibung", "lessonDescription": "Lektions-Beschreibung",
"culturalNotes": "Kulturelle Notizen", "culturalNotes": "Kulturelle Notizen",
"grammarExplanations": "Grammatik-Erklärungen", "grammarExplanations": "Grammatik-Erklärungen",
"grammarImpulse": "Grammatik-Impuls",
"learningGoals": "Lernziele",
"corePatterns": "Kernmuster",
"speakingTasks": "Sprechaufträge",
"speakingPrompt": "Sprechauftrag",
"practicalTasks": "Praxisaufgaben",
"importantVocab": "Wichtige Begriffe", "importantVocab": "Wichtige Begriffe",
"vocabInfoText": "Diese Begriffe werden in der Prüfung verwendet. Lerne sie hier passiv, bevor du zur Kapitel-Prüfung wechselst.", "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.", "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?", "goToNextLesson": "Zur nächsten Lektion wechseln?",
"allLessonsCompleted": "Alle Lektionen abgeschlossen!", "allLessonsCompleted": "Alle Lektionen abgeschlossen!",
"startExercises": "Zur Kapitel-Prüfung", "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", "correctAnswer": "Richtige Antwort",
"alternatives": "Alternative Antworten", "alternatives": "Alternative Antworten",
"notStarted": "Nicht begonnen", "notStarted": "Nicht begonnen",
"continueCurrentLesson": "Zur aktuellen Lektion", "continueCurrentLesson": "Zur aktuellen Lektion",
"previousLessonRequired": "Bitte schließe zuerst die vorherige Lektion ab", "previousLessonRequired": "Bitte schließe zuerst die vorherige Lektion ab",
"lessonNumberShort": "#", "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.", "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.", "speakingFromMemoryInstruction": "Sprich frei aus dem Kopf. Verwende die angezeigten Schlüsselwörter.",
"startRecording": "Aufnahme starten", "startRecording": "Aufnahme starten",

View File

@@ -61,7 +61,8 @@
"flirt": "Flirt", "flirt": "Flirt",
"interests": "Interests", "interests": "Interests",
"notifications": "Notifications", "notifications": "Notifications",
"sexuality": "Sexuality" "sexuality": "Sexuality",
"languageAssistant": "Language assistant"
}, },
"m-administration": { "m-administration": {
"contactrequests": "Contact requests", "contactrequests": "Contact requests",

View File

@@ -150,6 +150,27 @@
"changeaction": "Change User Data", "changeaction": "Change User Data",
"oldpassword": "Old Password (required)" "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": { "interests": {
"title": "Interests", "title": "Interests",
"new": "New Interest", "new": "New Interest",

View File

@@ -370,9 +370,16 @@
"learn": "Learn", "learn": "Learn",
"exercises": "Chapter Test", "exercises": "Chapter Test",
"learnVocabulary": "Learn Vocabulary", "learnVocabulary": "Learn Vocabulary",
"lessonOverviewText": "This lesson combines vocabulary, patterns, short grammar impulses, and active speaking practice.",
"lessonDescription": "Lesson Description", "lessonDescription": "Lesson Description",
"culturalNotes": "Cultural Notes", "culturalNotes": "Cultural Notes",
"grammarExplanations": "Grammar Explanations", "grammarExplanations": "Grammar Explanations",
"grammarImpulse": "Grammar Focus",
"learningGoals": "Learning Goals",
"corePatterns": "Core Patterns",
"speakingTasks": "Speaking Tasks",
"speakingPrompt": "Speaking Prompt",
"practicalTasks": "Practical Tasks",
"importantVocab": "Important Vocabulary", "importantVocab": "Important Vocabulary",
"vocabInfoText": "These terms are used in the test. Learn them here passively before switching to the chapter test.", "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.", "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?", "goToNextLesson": "Go to next lesson?",
"allLessonsCompleted": "All lessons completed!", "allLessonsCompleted": "All lessons completed!",
"startExercises": "Start Chapter Test", "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", "correctAnswer": "Correct Answer",
"alternatives": "Alternative Answers", "alternatives": "Alternative Answers",
"notStarted": "Not Started", "notStarted": "Not Started",
"continueCurrentLesson": "Continue Current Lesson", "continueCurrentLesson": "Continue Current Lesson",
"previousLessonRequired": "Please complete the previous lesson first", "previousLessonRequired": "Please complete the previous lesson first",
"lessonNumberShort": "#", "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.", "readingAloudInstruction": "Read the text aloud. Click 'Start Recording' and begin speaking.",
"speakingFromMemoryInstruction": "Speak freely from memory. Use the displayed keywords.", "speakingFromMemoryInstruction": "Speak freely from memory. Use the displayed keywords.",
"startRecording": "Start Recording", "startRecording": "Start Recording",

View File

@@ -61,7 +61,8 @@
"flirt": "Flirt", "flirt": "Flirt",
"interests": "Interessen", "interests": "Interessen",
"notifications": "Notificaciones", "notifications": "Notificaciones",
"sexuality": "Sexualidad" "sexuality": "Sexualidad",
"languageAssistant": "Asistente de idiomas"
}, },
"m-administration": { "m-administration": {
"contactrequests": "Solicitudes de contacto", "contactrequests": "Solicitudes de contacto",

View File

@@ -150,6 +150,27 @@
"changeaction": "Actualizar datos de usuario", "changeaction": "Actualizar datos de usuario",
"oldpassword": "Contraseña anterior (obligatoria)" "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": { "interests": {
"title": "Intereses", "title": "Intereses",
"new": "Nuevo interés", "new": "Nuevo interés",

View File

@@ -367,9 +367,16 @@
"learn": "Aprender", "learn": "Aprender",
"exercises": "Prueba del capítulo", "exercises": "Prueba del capítulo",
"learnVocabulary": "Aprender vocabulario", "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", "lessonDescription": "Descripción de la lección",
"culturalNotes": "Notas culturales", "culturalNotes": "Notas culturales",
"grammarExplanations": "Explicaciones gramaticales", "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", "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.", "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.", "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?", "goToNextLesson": "¿Pasar a la siguiente lección?",
"allLessonsCompleted": "¡Todas las lecciones completadas!", "allLessonsCompleted": "¡Todas las lecciones completadas!",
"startExercises": "Ir a la prueba del capítulo", "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", "correctAnswer": "Respuesta correcta",
"alternatives": "Respuestas alternativas", "alternatives": "Respuestas alternativas",
"notStarted": "No empezado", "notStarted": "No empezado",
"continueCurrentLesson": "Continuar lección actual", "continueCurrentLesson": "Continuar lección actual",
"previousLessonRequired": "Primero completa la lección anterior", "previousLessonRequired": "Primero completa la lección anterior",
"lessonNumberShort": "#", "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.", "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.", "speakingFromMemoryInstruction": "Habla de memoria. Usa las palabras clave mostradas.",
"startRecording": "Iniciar grabación", "startRecording": "Iniciar grabación",

View File

@@ -4,6 +4,7 @@ const FlirtSettingsView = () => import('../views/settings/FlirtView.vue');
const SexualitySettingsView = () => import('../views/settings/SexualityView.vue'); const SexualitySettingsView = () => import('../views/settings/SexualityView.vue');
const AccountSettingsView = () => import('../views/settings/AccountView.vue'); const AccountSettingsView = () => import('../views/settings/AccountView.vue');
const InterestsView = () => import('../views/settings/InterestsView.vue'); const InterestsView = () => import('../views/settings/InterestsView.vue');
const LanguageAssistantView = () => import('../views/settings/LanguageAssistantView.vue');
const settingsRoutes = [ const settingsRoutes = [
{ {
@@ -42,6 +43,12 @@ const settingsRoutes = [
component: InterestsView, component: InterestsView,
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/settings/language-assistant',
name: 'Language assistant settings',
component: LanguageAssistantView,
meta: { requiresAuth: true }
},
]; ];
export default settingsRoutes; export default settingsRoutes;

View File

@@ -0,0 +1,224 @@
<template>
<div class="language-assistant-settings">
<section class="language-assistant-settings__hero surface-card">
<span class="language-assistant-settings__eyebrow">{{ $t('settings.languageAssistant.eyebrow') }}</span>
<h2>{{ $t('settings.languageAssistant.title') }}</h2>
<p class="language-assistant-settings__intro">{{ $t('settings.languageAssistant.intro') }}</p>
<ul class="language-assistant-settings__links">
<li>
<a href="https://platform.openai.com/signup" target="_blank" rel="noopener noreferrer">
{{ $t('settings.languageAssistant.linkSignup') }}
</a>
</li>
<li>
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">
{{ $t('settings.languageAssistant.linkApiKeys') }}
</a>
</li>
</ul>
</section>
<section v-if="loadError" class="surface-card language-assistant-settings__panel">
<p class="form-error">{{ loadError }}</p>
</section>
<section v-else class="language-assistant-settings__panel surface-card">
<label class="language-assistant-settings__toggle">
<input type="checkbox" v-model="form.enabled" />
<span>{{ $t('settings.languageAssistant.enabled') }}</span>
</label>
<div class="language-assistant-settings__grid">
<label class="language-assistant-settings__field">
<span>{{ $t('settings.languageAssistant.baseUrl') }}</span>
<input
v-model="form.baseUrl"
type="url"
autocomplete="off"
:placeholder="$t('settings.languageAssistant.baseUrlPlaceholder')"
/>
</label>
<label class="language-assistant-settings__field">
<span>{{ $t('settings.languageAssistant.model') }}</span>
<input v-model="form.model" type="text" autocomplete="off" placeholder="gpt-4o-mini" />
</label>
<label class="language-assistant-settings__field language-assistant-settings__field--full">
<span>{{ $t('settings.languageAssistant.apiKey') }}</span>
<input
v-model="form.apiKey"
type="password"
autocomplete="new-password"
:placeholder="apiKeyPlaceholder"
/>
<span class="language-assistant-settings__hint">{{ $t('settings.languageAssistant.apiKeyHint') }}</span>
</label>
</div>
<label class="language-assistant-settings__toggle">
<input type="checkbox" v-model="form.clearKey" />
<span>{{ $t('settings.languageAssistant.clearKey') }}</span>
</label>
<div class="language-assistant-settings__actions">
<button type="button" :disabled="saving" @click="save">{{ $t('settings.languageAssistant.save') }}</button>
</div>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex';
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'LanguageAssistantSettingsView',
data() {
return {
form: {
enabled: true,
baseUrl: '',
model: 'gpt-4o-mini',
apiKey: '',
clearKey: false
},
hasKey: false,
keyLast4: null,
saving: false,
loadError: null
};
},
computed: {
...mapGetters(['user']),
apiKeyPlaceholder() {
if (this.form.clearKey) {
return this.$t('settings.languageAssistant.apiKeyPlaceholderClear');
}
if (this.hasKey) {
return this.$t('settings.languageAssistant.apiKeyPlaceholderHasKey', {
last4: this.keyLast4 || '••••'
});
}
return this.$t('settings.languageAssistant.apiKeyPlaceholderNew');
}
},
async mounted() {
await this.load();
},
methods: {
async load() {
this.loadError = null;
try {
const { data } = await apiClient.get('/api/settings/llm');
this.form.enabled = data.enabled !== false;
this.form.baseUrl = data.baseUrl || '';
this.form.model = data.model || 'gpt-4o-mini';
this.hasKey = data.hasKey;
this.keyLast4 = data.keyLast4;
this.form.apiKey = '';
this.form.clearKey = false;
} catch (e) {
this.loadError = e.response?.data?.error || e.message || 'Error';
}
},
async save() {
if (this.form.clearKey && !window.confirm(this.$t('settings.languageAssistant.confirmClear'))) {
return;
}
this.saving = true;
try {
await apiClient.post('/api/settings/llm', {
enabled: this.form.enabled,
baseUrl: this.form.baseUrl,
model: this.form.model,
apiKey: this.form.apiKey,
clearKey: this.form.clearKey
});
showSuccess(this, this.$t('settings.languageAssistant.saved'));
this.form.apiKey = '';
this.form.clearKey = false;
await this.load();
} catch (e) {
showApiError(this, e, () => showError(this, this.$t('settings.languageAssistant.saveError')));
} finally {
this.saving = false;
}
}
}
};
</script>
<style scoped>
.language-assistant-settings__hero {
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
}
.language-assistant-settings__eyebrow {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.75;
margin-bottom: 0.35rem;
}
.language-assistant-settings__intro {
margin: 0.5rem 0 1rem;
line-height: 1.5;
}
.language-assistant-settings__links {
margin: 0;
padding-left: 1.25rem;
}
.language-assistant-settings__links a {
color: var(--color-link, #3b82f6);
}
.language-assistant-settings__panel {
padding: 1.25rem 1.5rem;
}
.language-assistant-settings__grid {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
@media (min-width: 640px) {
.language-assistant-settings__grid {
grid-template-columns: 1fr 1fr;
}
.language-assistant-settings__field--full {
grid-column: 1 / -1;
}
}
.language-assistant-settings__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.language-assistant-settings__field span:first-child {
font-weight: 600;
font-size: 0.9rem;
}
.language-assistant-settings__field input {
padding: 0.5rem 0.65rem;
border-radius: 6px;
border: 1px solid var(--color-border, #ccc);
background: var(--color-input-bg, #fff);
color: inherit;
}
.language-assistant-settings__hint {
font-size: 0.8rem;
opacity: 0.8;
}
.language-assistant-settings__toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
cursor: pointer;
}
.language-assistant-settings__actions {
margin-top: 1.25rem;
}
.form-error {
color: #c62828;
}
</style>

View File

@@ -30,26 +30,80 @@
<!-- Lernen-Tab --> <!-- Lernen-Tab -->
<div v-if="activeTab === 'learn'" class="learn-section"> <div v-if="activeTab === 'learn'" class="learn-section">
<div class="lesson-overview-card">
<div>
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3> <h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
<p class="lesson-overview-text">
{{ $t('socialnetwork.vocab.courses.lessonOverviewText') }}
</p>
</div>
<div class="lesson-meta-grid">
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonTypeLabel') }}</span>
<strong>{{ getLessonTypeLabel(lesson.lessonType) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.recommendedDuration') }}</span>
<strong>{{ formatTargetMinutes(lesson.targetMinutes) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.exerciseLoad') }}</span>
<strong>{{ effectiveExercises?.length || 0 }} {{ $t('socialnetwork.vocab.courses.exercisesShort') }}</strong>
</div>
</div>
</div>
<!-- Lektions-Beschreibung --> <div class="learn-grid">
<div v-if="lesson && lesson.description" class="lesson-description-box"> <div v-if="lesson && lesson.description" class="lesson-description-box">
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4> <h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
<p>{{ lesson.description }}</p> <p>{{ lesson.description }}</p>
</div> </div>
<!-- Kulturelle Notizen --> <div v-if="lessonDidactics.learningGoals.length > 0" class="didactic-card">
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes"> <h4>{{ $t('socialnetwork.vocab.courses.learningGoals') }}</h4>
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4> <ul class="didactic-list">
<p>{{ lesson.culturalNotes }}</p> <li v-for="(goal, index) in lessonDidactics.learningGoals" :key="'goal-' + index">{{ goal }}</li>
</ul>
</div> </div>
<!-- Grammatik-Erklärungen --> <div v-if="lessonDidactics.corePatterns.length > 0" class="didactic-card">
<div v-if="grammarExplanations && grammarExplanations.length > 0" class="grammar-explanations"> <h4>{{ $t('socialnetwork.vocab.courses.corePatterns') }}</h4>
<div class="pattern-list">
<div v-for="(pattern, index) in lessonDidactics.corePatterns" :key="'pattern-' + index" class="pattern-item">
{{ pattern }}
</div>
</div>
</div>
<div v-if="lessonDidactics.grammarFocus.length > 0" class="grammar-explanations didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4> <h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4>
<div v-for="(explanation, index) in grammarExplanations" :key="index" class="grammar-explanation-item"> <div v-for="(explanation, index) in lessonDidactics.grammarFocus" :key="'grammar-' + index" class="grammar-explanation-item">
<strong>{{ explanation.title }}</strong> <strong>{{ explanation.title || $t('socialnetwork.vocab.courses.grammarImpulse') }}</strong>
<p>{{ explanation.text }}</p> <p>{{ explanation.text }}</p>
<p v-if="explanation.example" class="grammar-example">{{ explanation.example }}</p>
</div>
</div>
<div v-if="lessonDidactics.speakingPrompts.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.speakingTasks') }}</h4>
<div v-for="(prompt, index) in lessonDidactics.speakingPrompts" :key="'speaking-' + index" class="speaking-prompt-item">
<strong>{{ prompt.title || $t('socialnetwork.vocab.courses.speakingPrompt') }}</strong>
<p>{{ prompt.prompt }}</p>
<p v-if="prompt.cue" class="speaking-cue">{{ prompt.cue }}</p>
</div>
</div>
<div v-if="lessonDidactics.practicalTasks.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.practicalTasks') }}</h4>
<div v-for="(task, index) in lessonDidactics.practicalTasks" :key="'task-' + index" class="practical-task-item">
<strong>{{ task.title }}</strong>
<p>{{ task.text }}</p>
</div>
</div>
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
</div> </div>
</div> </div>
@@ -253,6 +307,94 @@
</div> </div>
</div> </div>
<div v-else-if="getExerciseType(exercise) === 'sentence_building'" class="sentence-building-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.tokens?.length" class="token-list">
<span v-for="(token, index) in getQuestionData(exercise).tokens" :key="index" class="token-chip">{{ token }}</span>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.buildSentencePlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelSentence') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'dialog_completion'" class="dialog-completion-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.dialog" class="dialog-snippet">
<p v-for="(line, index) in getQuestionData(exercise).dialog" :key="index">{{ line }}</p>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.completeDialogPlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelDialogLine') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'situational_response'" class="situational-response-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<textarea
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.situationalResponsePlaceholder')"
class="response-textarea"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelResponse') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.keywords') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'pattern_drill'" class="pattern-drill-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<p v-if="getQuestionData(exercise)?.pattern" class="pattern-drill-hint">
{{ $t('socialnetwork.vocab.courses.patternPrompt') }}: {{ getQuestionData(exercise).pattern }}
</p>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.patternDrillPlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelPattern') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<!-- Reading Aloud Übung --> <!-- Reading Aloud Übung -->
<div v-else-if="getExerciseType(exercise) === 'reading_aloud'" class="reading-aloud-exercise"> <div v-else-if="getExerciseType(exercise) === 'reading_aloud'" class="reading-aloud-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p> <p class="exercise-question">{{ getQuestionText(exercise) }}</p>
@@ -543,6 +685,15 @@ export default {
console.error('Fehler in importantVocab computed property:', e); console.error('Fehler in importantVocab computed property:', e);
return []; return [];
} }
},
lessonDidactics() {
return this.lesson?.didactics || {
learningGoals: [],
corePatterns: [],
grammarFocus: [],
speakingPrompts: [],
practicalTasks: []
};
} }
}, },
watch: { watch: {
@@ -787,10 +938,31 @@ export default {
5: 'conjugation', 5: 'conjugation',
6: 'declension', 6: 'declension',
7: 'reading_aloud', 7: 'reading_aloud',
8: 'speaking_from_memory' 8: 'speaking_from_memory',
9: 'dialog_completion',
10: 'situational_response',
11: 'pattern_drill'
}; };
return typeMap[exercise.exerciseTypeId] || 'unknown'; return typeMap[exercise.exerciseTypeId] || 'unknown';
}, },
getLessonTypeLabel(lessonType) {
const labels = {
vocab: this.$t('socialnetwork.vocab.courses.lessonTypeVocab'),
grammar: this.$t('socialnetwork.vocab.courses.lessonTypeGrammar'),
conversation: this.$t('socialnetwork.vocab.courses.lessonTypeConversation'),
culture: this.$t('socialnetwork.vocab.courses.lessonTypeCulture'),
review: this.$t('socialnetwork.vocab.courses.lessonTypeReview'),
vocab_review: this.$t('socialnetwork.vocab.courses.lessonTypeReview')
};
return labels[lessonType] || lessonType || this.$t('socialnetwork.vocab.courses.lessonTypeVocab');
},
formatTargetMinutes(targetMinutes) {
const minutes = Number(targetMinutes);
if (!minutes) {
return this.$t('socialnetwork.vocab.courses.durationFlexible');
}
return this.$t('socialnetwork.vocab.courses.durationMinutes', { minutes });
},
getQuestionData(exercise) { getQuestionData(exercise) {
if (!exercise.questionData) return null; if (!exercise.questionData) return null;
return typeof exercise.questionData === 'string' return typeof exercise.questionData === 'string'
@@ -861,9 +1033,11 @@ export default {
} else if (exerciseType === 'multiple_choice') { } else if (exerciseType === 'multiple_choice') {
// Multiple Choice: Index als Zahl // Multiple Choice: Index als Zahl
answer = Number(answer); answer = Number(answer);
} else if (exerciseType === 'transformation') { } else if (exerciseType === 'transformation' || exerciseType === 'sentence_building' || exerciseType === 'dialog_completion' || exerciseType === 'pattern_drill') {
// Transformation: String // Transformation: String
answer = String(answer || '').trim(); answer = String(answer || '').trim();
} else if (exerciseType === 'situational_response') {
answer = String(answer || '').trim();
} else if (exerciseType === 'reading_aloud' || exerciseType === 'speaking_from_memory') { } else if (exerciseType === 'reading_aloud' || exerciseType === 'speaking_from_memory') {
// Reading Aloud / Speaking From Memory: Verwende erkannten Text // Reading Aloud / Speaking From Memory: Verwende erkannten Text
answer = this.recognizedText[exerciseId] || String(answer || '').trim(); answer = this.recognizedText[exerciseId] || String(answer || '').trim();
@@ -1453,6 +1627,126 @@ export default {
margin-bottom: 20px; margin-bottom: 20px;
} }
.lesson-overview-card {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 20px;
margin-bottom: 20px;
background: linear-gradient(135deg, #fff8eb 0%, #f7efe2 100%);
border: 1px solid rgba(160, 120, 40, 0.18);
border-radius: 12px;
}
.lesson-overview-text {
margin: 8px 0 0;
color: #5b4b2f;
}
.lesson-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(130px, 1fr));
gap: 12px;
min-width: 360px;
}
.lesson-meta-item {
padding: 12px 14px;
background: rgba(255, 255, 255, 0.72);
border-radius: 10px;
}
.lesson-meta-label {
display: block;
margin-bottom: 6px;
font-size: 0.82rem;
color: #7a6848;
}
.learn-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.didactic-card,
.lesson-description-box,
.cultural-notes {
padding: 18px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e7e7e7;
}
.didactic-list {
margin: 0;
padding-left: 20px;
}
.didactic-list li + li {
margin-top: 8px;
}
.pattern-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.pattern-item {
padding: 12px 14px;
border-left: 4px solid #d2831f;
background: #fff;
border-radius: 8px;
}
.grammar-example,
.speaking-cue,
.pattern-drill-hint {
margin-top: 8px;
color: #66553a;
font-style: italic;
}
.speaking-prompt-item + .speaking-prompt-item,
.practical-task-item + .practical-task-item {
margin-top: 14px;
}
.token-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0;
}
.token-chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: #eef3ff;
border: 1px solid #cfdbff;
border-radius: 999px;
font-size: 0.95rem;
}
.dialog-snippet {
margin: 14px 0;
padding: 14px;
background: #fff;
border-radius: 10px;
border: 1px solid #e6e6e6;
}
.response-textarea {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid #d0d0d0;
border-radius: 8px;
resize: vertical;
}
.btn-back { .btn-back {
padding: 8px 16px; padding: 8px 16px;
border: 1px solid #ddd; border: 1px solid #ddd;
@@ -2023,7 +2317,11 @@ export default {
/* Reading Aloud & Speaking From Memory Styles */ /* Reading Aloud & Speaking From Memory Styles */
.reading-aloud-exercise, .reading-aloud-exercise,
.speaking-from-memory-exercise { .speaking-from-memory-exercise,
.sentence-building-exercise,
.dialog-completion-exercise,
.situational-response-exercise,
.pattern-drill-exercise {
padding: 20px; padding: 20px;
background: #f8f9fa; background: #f8f9fa;
border-radius: 8px; border-radius: 8px;
@@ -2229,4 +2527,15 @@ export default {
.dialog-button:hover { .dialog-button:hover {
background: #0056b3; background: #0056b3;
} }
@media (max-width: 900px) {
.lesson-overview-card {
flex-direction: column;
}
.lesson-meta-grid {
grid-template-columns: 1fr;
min-width: 0;
}
}
</style> </style>

View File

@@ -0,0 +1,22 @@
# Kopiere nach .env und trage echte Werte ein (.env nicht committen).
# Pflicht für LLM-Tools (ein Key reicht)
LANGUAGE_COURSE_LLM_API_KEY=
# Alternativ:
# OPENAI_API_KEY=
# Optional: anderes Modell
# LANGUAGE_COURSE_LLM_MODEL=gpt-4o-mini
# Optional: OpenAI-kompatibler Endpunkt
# OpenAI-Default wird verwendet, wenn leer.
# LANGUAGE_COURSE_LLM_BASE_URL=https://api.openai.com/v1
# Lokal z. B. Ollama (OpenAI-kompatibel):
# LANGUAGE_COURSE_LLM_BASE_URL=http://127.0.0.1:11434/v1
# LANGUAGE_COURSE_LLM_MODEL=llama3.2
# Ollama erwartet oft einen Dummy-Key:
# LANGUAGE_COURSE_LLM_API_KEY=ollama
# Glossar-Daten (optional)
# LANGUAGE_COURSE_MCP_DATA=/absoluter/pfad/zu/data

View File

@@ -0,0 +1,85 @@
# Language Course MCP Server
Model-Context-Protocol-Server zum **Ergänzen und Abfragen** von Sprachkurs-Inhalten: Glossar (Begriffe/Phrasen), neue Einträge speichern, **Konversationsübungen** als Rollenspiel-Prompts.
Liegt bewusst **neben** der YourPart-App (`mcp/language-course-server/`); Daten sind zunächst eine lokale JSON-Datei. Du kannst Inhalte später in `backend/scripts/create-language-courses.js` / Vocab-Modelle oder per API spiegeln.
## Tools
| Tool | Zweck |
|------|--------|
| `search_terms` | Glossar durchsuchen (`query`, optional `targetLang`, `nativeLang`) |
| `add_phrase` | Neue Phrase in `glossary.json` schreiben |
| `conversation_practice` | Zufälliges Szenario zum Üben (optional `topicHint`) |
| `export_glossary_snippet` | Letzte `n` Einträge als JSON für Export |
| `llm_status` | Prüft, ob API-Key/Modell gesetzt sind (ohne Netz) |
| `llm_chat` | Freier Chat mit dem LLM (`userMessage`, optional `systemPrompt`) |
| `llm_language_tutor` | `mode: dialogue` (Rollenspiel in Zielsprache) oder `evaluate` (Feedback zur Antwort) |
## LLM-Anbindung (OpenAI-kompatibel)
Voraussetzung für `llm_*`: ein **API-Key** und optional **Base-URL** / **Modell**.
| Variable | Bedeutung |
|----------|-----------|
| `LANGUAGE_COURSE_LLM_API_KEY` oder `OPENAI_API_KEY` | Pflicht für LLM-Tools |
| `LANGUAGE_COURSE_LLM_BASE_URL` | Leer = OpenAI-Default; z.B. `http://127.0.0.1:11434/v1` (Ollama) |
| `LANGUAGE_COURSE_LLM_MODEL` | Default: `gpt-4o-mini` |
Lokal: `cp .env.example .env` und Key eintragen. Beim Start wird `mcp/language-course-server/.env` geladen (sofern vorhanden).
**Cursor:** dieselben Variablen im `env`-Block des MCP-Eintrags setzen (oder nur Pfad zur `.env` vermeiden und Keys dort nicht duplizieren dann Server per Wrapper starten, der `dotenv` lädt; unser Server lädt `.env` automatisch aus seinem Ordner).
**Ollama-Beispiel:**
```bash
LANGUAGE_COURSE_LLM_BASE_URL=http://127.0.0.1:11434/v1
LANGUAGE_COURSE_LLM_MODEL=llama3.2
LANGUAGE_COURSE_LLM_API_KEY=ollama
```
## Daten
- Standard: `data/glossary.json` (wird bei Bedarf angelegt).
- Alternativ: Umgebungsvariable `LANGUAGE_COURSE_MCP_DATA` auf ein Verzeichnis setzen (dort liegt `glossary.json`).
## Installation
```bash
cd mcp/language-course-server && npm install
```
## Cursor einbinden
In den Cursor-Einstellungen unter **MCP** einen Server hinzufügen, z.B.:
```json
{
"mcpServers": {
"language-course": {
"command": "node",
"args": ["/ABSOLUTER/PFAD/zu/YourPart3/mcp/language-course-server/src/server.mjs"],
"env": {
"LANGUAGE_COURSE_MCP_DATA": "/ABSOLUTER/PFAD/zu/YourPart3/mcp/language-course-server/data",
"OPENAI_API_KEY": "sk-…"
}
}
}
}
```
`args` und `LANGUAGE_COURSE_MCP_DATA` an deinen Rechner anpassen.
## Manuell testen
```bash
cd mcp/language-course-server && npm run inspector
```
Öffnet den MCP Inspector; dort Tools ausprobieren.
## Hinweis zur App
Die Sprachkurse in der App nutzen `VocabCourse` / `vocabService` (siehe `backend/`). Dieser MCP ist eine **autororientierte Ergänzung** für deinen Workflow in der IDE.
**YourPart-Web:** Unter **Einstellungen → Sprachassistent** (`/settings/language-assistant`) speichern Nutzer **API-Key** und Metadaten in **`community.user_param`** (Typen `llm_settings` / `llm_api_key`, Gruppe `languageAssistant` in `type.settings`) — verschlüsselt wie andere Profilparameter. Der lokale MCP nutzt weiterhin die Umgebungsvariablen hier; beides ist getrennt, bis ein Backend-Proxy die gespeicherten Werte nutzt.

View File

@@ -0,0 +1,14 @@
{
"version": 1,
"entries": [
{
"id": "ex-1",
"targetLang": "de",
"nativeLang": "en",
"term": "Guten Tag",
"translation": "Good day",
"context": "formal greeting",
"tags": ["greeting", "A1"]
}
]
}

View File

@@ -0,0 +1,4 @@
{
"version": 1,
"entries": []
}

View File

@@ -0,0 +1,23 @@
{
"name": "language-course-mcp-server",
"version": "1.0.0",
"description": "MCP-Server: Sprachkurs Begriffe/Phrasen, Ergänzungen, Konversationsübung",
"type": "module",
"private": true,
"bin": {
"language-course-mcp": "./src/server.mjs"
},
"scripts": {
"start": "node src/server.mjs",
"inspect": "npx @modelcontextprotocol/inspector node src/server.mjs"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"dotenv": "^16.4.5",
"openai": "^4.77.0",
"zod": "^3.24.0"
}
}

View File

@@ -0,0 +1,58 @@
/**
* OpenAI-kompatible Chat-API (OpenAI, Azure OpenAI, Ollama, LM Studio, vLLM, …).
*
* Umgebung:
* LANGUAGE_COURSE_LLM_API_KEY oder OPENAI_API_KEY
* LANGUAGE_COURSE_LLM_BASE_URL (optional, z. B. http://127.0.0.1:11434/v1 für Ollama)
* LANGUAGE_COURSE_LLM_MODEL (optional, Default: gpt-4o-mini)
*/
import OpenAI from 'openai';
export function getLlmEnv() {
const apiKey =
process.env.LANGUAGE_COURSE_LLM_API_KEY ||
process.env.OPENAI_API_KEY ||
'';
const baseURL = process.env.LANGUAGE_COURSE_LLM_BASE_URL || undefined;
const model = process.env.LANGUAGE_COURSE_LLM_MODEL || 'gpt-4o-mini';
return { apiKey, baseURL, model };
}
export function isLlmConfigured() {
return Boolean(getLlmEnv().apiKey);
}
export function getOpenAiClient() {
const { apiKey, baseURL } = getLlmEnv();
if (!apiKey) return null;
return new OpenAI({
apiKey,
baseURL,
});
}
/**
* @param {import('openai').ChatCompletionMessageParam[]} messages
* @param {{ model?: string; temperature?: number; maxTokens?: number }} [opts]
*/
export async function chatComplete(messages, opts = {}) {
const client = getOpenAiClient();
if (!client) {
throw new Error(
'Kein LLM konfiguriert: LANGUAGE_COURSE_LLM_API_KEY oder OPENAI_API_KEY setzen (siehe README).'
);
}
const { model, temperature = 0.6, maxTokens = 2048 } = opts;
const m = model || getLlmEnv().model;
const res = await client.chat.completions.create({
model: m,
messages,
temperature,
max_tokens: maxTokens,
});
const text = res.choices[0]?.message?.content?.trim() || '';
if (!text) {
throw new Error('Leere Antwort vom Modell.');
}
return text;
}

View File

@@ -0,0 +1,302 @@
#!/usr/bin/env node
/**
* MCP-Server: Sprachkurs-Ergänzung (Begriffe, Phrasen, Konversationsübung).
*
* Daten: JSON unter LANGUAGE_COURSE_MCP_DATA (Standard: ../data), Datei glossary.json
* LLM: LANGUAGE_COURSE_LLM_* / OPENAI_API_KEY (siehe README, .env.example)
*/
import { config as loadDotenv } from 'dotenv';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
import { chatComplete, getLlmEnv, isLlmConfigured } from './llm.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
loadDotenv({ path: path.join(ROOT, '.env') });
const DATA_DIR = process.env.LANGUAGE_COURSE_MCP_DATA
? path.resolve(process.env.LANGUAGE_COURSE_MCP_DATA)
: path.join(ROOT, 'data');
const GLOSSARY_FILE = path.join(DATA_DIR, 'glossary.json');
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
}
function loadGlossary() {
ensureDataDir();
if (!fs.existsSync(GLOSSARY_FILE)) {
const empty = { version: 1, entries: [] };
fs.writeFileSync(GLOSSARY_FILE, JSON.stringify(empty, null, 2), 'utf8');
return empty;
}
const raw = fs.readFileSync(GLOSSARY_FILE, 'utf8');
return JSON.parse(raw);
}
function saveGlossary(data) {
ensureDataDir();
fs.writeFileSync(GLOSSARY_FILE, JSON.stringify(data, null, 2), 'utf8');
}
const server = new McpServer({
name: 'language-course',
version: '1.0.0',
});
server.tool(
'search_terms',
'Begriffe und Phrasen im Glossar durchsuchen (Teilstring in term, translation, context, tags).',
{
query: z.string().describe('Suchtext (leer = alles)'),
targetLang: z.string().optional().describe('ISO/Sprachname filter, z. B. de'),
nativeLang: z.string().optional().describe('Filter Muttersprache, z. B. en'),
limit: z.number().int().min(1).max(100).optional().default(20),
},
async ({ query, targetLang, nativeLang, limit }) => {
const g = loadGlossary();
const q = (query || '').trim().toLowerCase();
let list = g.entries || [];
if (targetLang) {
list = list.filter((e) => (e.targetLang || '').toLowerCase() === targetLang.toLowerCase());
}
if (nativeLang) {
list = list.filter((e) => (e.nativeLang || '').toLowerCase() === nativeLang.toLowerCase());
}
if (q) {
list = list.filter((e) => {
const hay = [
e.term,
e.translation,
e.context,
...(Array.isArray(e.tags) ? e.tags : []),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return hay.includes(q);
});
}
list = list.slice(0, limit);
const text = list.length
? JSON.stringify(list, null, 2)
: 'Keine Treffer.';
return { content: [{ type: 'text', text }] };
}
);
server.tool(
'add_phrase',
'Neue Phrase oder Begriff zum Glossar hinzufügen (schreibt glossary.json).',
{
targetLang: z.string().describe('Zielsprache, z. B. de'),
term: z.string().describe('Ausdruck in der Zielsprache'),
translation: z.string().describe('Übersetzung oder Erklärung'),
nativeLang: z.string().optional().describe('Muttersprache/Lernkontext, z. B. en'),
context: z.string().optional().describe('Kontext oder Beispielsatz'),
tags: z.array(z.string()).optional().describe('Stichwörter, z. B. ["travel","A2"]'),
},
async ({ targetLang, term, translation, nativeLang, context, tags }) => {
const g = loadGlossary();
const id = crypto.randomUUID();
g.entries = g.entries || [];
g.entries.push({
id,
targetLang,
nativeLang: nativeLang || null,
term: term.trim(),
translation: translation.trim(),
context: context || null,
tags: tags || [],
});
saveGlossary(g);
return {
content: [
{
type: 'text',
text: `Eintrag gespeichert (id=${id}). Datei: ${GLOSSARY_FILE}`,
},
],
};
}
);
const SCENARIOS = [
{
topic: 'Bahnhof / Tickets',
prompt:
'Du bist am Schalter. Frage höflich nach einer Fahrkarte nach … (Ziel nennen). Reagiere auf Rückfragen.',
},
{
topic: 'Restaurant',
prompt:
'Bestelle ein Gericht, frage nach Allergenen, bitte um die Rechnung. Halte den Dialog kurz und natürlich.',
},
{
topic: 'Arzt Termin',
prompt:
'Rufe an oder sprich am Empfang: Du brauchst einen Termin und beschreibst kurz dein Anliegen.',
},
{
topic: 'Smalltalk Wetter',
prompt:
'Führe ein 1-minütiges Gespräch über das Wetter und das Wochenende keine Fakten nötig, nur flüssig bleiben.',
},
];
server.tool(
'conversation_practice',
'Liefert ein kurzes Rollenspiel-Szenario zum Üben (ohne Bewertung). Optional mit Fokus-Thema.',
{
topicHint: z.string().optional().describe('z. B. Restaurant, Arzt, Bahnhof'),
targetLang: z.string().optional().describe('Nur für die Anzeige, z. B. Deutsch'),
},
async ({ topicHint, targetLang }) => {
let pick = SCENARIOS[Math.floor(Math.random() * SCENARIOS.length)];
if (topicHint) {
const h = topicHint.toLowerCase();
const found = SCENARIOS.find((s) => s.topic.toLowerCase().includes(h) || h.includes(s.topic.split(' ')[0].toLowerCase()));
if (found) pick = found;
}
const lines = [
`Zielsprache (Anzeige): ${targetLang || 'frei wählbar'}`,
`Thema: ${pick.topic}`,
'',
'Aufgabe:',
pick.prompt,
'',
'Tipp: Formuliere laut oder schriftlich 510 Äußerungen; danach kannst du mit search_terms passende Redewendungen nachschlagen.',
];
return { content: [{ type: 'text', text: lines.join('\n') }] };
}
);
server.tool(
'export_glossary_snippet',
'Kopier-fertigen JSON-Ausschnitt der letzten n Einträge exportieren (für Import ins YourPart-Skript o. Ä.).',
{ limit: z.number().int().min(1).max(200).optional().default(30) },
async ({ limit }) => {
const g = loadGlossary();
const entries = (g.entries || []).slice(-limit);
const text = JSON.stringify({ version: g.version || 1, entries }, null, 2);
return { content: [{ type: 'text', text }] };
}
);
server.tool(
'llm_status',
'Prüft, ob ein LLM per API-Key und Modell erreichbar konfiguriert ist (kein Netzaufruf).',
{},
async () => {
const { model, baseURL } = getLlmEnv();
const ok = isLlmConfigured();
const text = [
`Konfiguriert: ${ok ? 'ja' : 'nein'}`,
`Modell (Default): ${model}`,
baseURL ? `Base URL: ${baseURL}` : 'Base URL: (OpenAI-Standard)',
'',
ok
? 'Hinweis: Echte Erreichbarkeit erst mit llm_chat oder llm_language_tutor testen.'
: 'Setze LANGUAGE_COURSE_LLM_API_KEY oder OPENAI_API_KEY (siehe .env.example).',
].join('\n');
return { content: [{ type: 'text', text }] };
}
);
server.tool(
'llm_chat',
'Freier Chat mit dem konfigurierten LLM (OpenAI-kompatibel). Für Erklärungen, Übersetzungen, freie Dialoge.',
{
userMessage: z.string().describe('Nutzer-/Assistenten-Anfrage'),
systemPrompt: z
.string()
.optional()
.describe('Systemrolle, z. B. „Du bist ein freundlicher Deutschlehrer …“'),
temperature: z.number().min(0).max(2).optional().default(0.6),
},
async ({ userMessage, systemPrompt, temperature }) => {
try {
const messages = [];
if (systemPrompt && systemPrompt.trim()) {
messages.push({ role: 'system', content: systemPrompt.trim() });
}
messages.push({ role: 'user', content: userMessage });
const out = await chatComplete(messages, { temperature });
return { content: [{ type: 'text', text: out }] };
} catch (e) {
return {
content: [{ type: 'text', text: e.message || String(e) }],
isError: true,
};
}
}
);
server.tool(
'llm_language_tutor',
'Rollenspiel oder Feedback: Das Modell antwortet in der Zielsprache (Dialog) oder gibt kurzes Feedback (evaluate).',
{
mode: z.enum(['dialogue', 'evaluate']).describe('dialogue = Konversation üben; evaluate = Antwort bewerten'),
targetLang: z.string().describe('Zielsprache, z. B. Deutsch'),
nativeLang: z.string().optional().describe('Erklärungen ggf. in dieser Sprache, z. B. Englisch'),
scenario: z.string().describe('Szenario oder Aufgabe (z. B. Restaurant, vorheriger conversation_practice-Text)'),
userMessage: z
.string()
.describe('Deine Äußerung in der Zielsprache (Dialog) bzw. deine Lösung (evaluate)'),
conversationContext: z
.string()
.optional()
.describe('Optional: bisheriger Dialog oder Lektionskontext'),
},
async ({ mode, targetLang, nativeLang, scenario, userMessage, conversationContext }) => {
try {
const explain = nativeLang || 'die Muttersprache des Lernenden';
let system;
if (mode === 'dialogue') {
system = [
`Du bist eine Gesprächspartnerin/ein Gesprächspartner für ${targetLang}.`,
`Spiele die andere Rolle im folgenden Szenario realistisch und kurz (13 Sätze pro Antwort).`,
`Antworte durchgehend in ${targetLang}.`,
`Wenn der Lernende einen Fehler macht, korrigiere ihn nicht ausführlich im Dialog; höchstens eine sanfte, kurze Rückmeldung.`,
`Erklärungen nur auf ${explain}, und nur wenn der Lernende ausdrücklich nach Hilfe fragt.`,
'',
`Szenario: ${scenario}`,
conversationContext ? `\nBisheriger Kontext:\n${conversationContext}` : '',
].join('\n');
} else {
system = [
`Du bist eine freundliche Sprachlehrerin für ${targetLang}.`,
`Der Lernende hat eine Aufgabe zum Szenario bearbeitet. Bewerte kurz (Stärken, 12 Verbesserungen).`,
`Antworte mit kurzem Feedback zuerst in ${explain}; optional ein korrektes Beispiel in ${targetLang}.`,
`Halte dich unter etwa 120 Wörtern.`,
'',
`Szenario/Aufgabe: ${scenario}`,
conversationContext ? `\nZusatzkontext:\n${conversationContext}` : '',
].join('\n');
}
const user = userMessage.trim();
const out = await chatComplete(
[
{ role: 'system', content: system },
{ role: 'user', content: user },
],
{ temperature: mode === 'dialogue' ? 0.75 : 0.4 }
);
return { content: [{ type: 'text', text: out }] };
} catch (e) {
return {
content: [{ type: 'text', text: e.message || String(e) }],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);