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

@@ -10,7 +10,6 @@ import InterestTranslation from '../models/type/interest_translation.js';
import { Op } from 'sequelize';
import UserParamVisibilityType from '../models/type/user_param_visibility.js';
import UserParamVisibility from '../models/community/user_param_visibility.js';
import { generateIv } from '../utils/encryption.js';
class SettingsService extends BaseService{
async getUserParams(userId, paramDescriptions) {
@@ -381,6 +380,129 @@ class SettingsService extends BaseService{
throw error;
}
}
/**
* LLM-/Sprachassistent: Werte in community.user_param, Typen in type.user_param,
* Gruppe type.settings.name = languageAssistant. API-Key separat (llm_api_key), Metadaten als JSON in llm_settings.
* Kein Klartext-Key an den Client.
*/
async getLlmSettings(hashedUserId) {
const user = await this.getUserByHashedId(hashedUserId);
const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
if (!settingsType || !apiKeyType) {
return {
enabled: true,
baseUrl: '',
model: 'gpt-4o-mini',
hasKey: false,
keyLast4: null
};
}
const settingsRow = await UserParam.findOne({
where: { userId: user.id, paramTypeId: settingsType.id }
});
const keyRow = await UserParam.findOne({
where: { userId: user.id, paramTypeId: apiKeyType.id }
});
let parsed = {};
if (settingsRow?.value) {
try {
parsed = JSON.parse(settingsRow.value);
} catch {
parsed = {};
}
}
const hasKey = Boolean(keyRow && keyRow.value && String(keyRow.value).trim());
return {
enabled: parsed.enabled !== false,
baseUrl: parsed.baseUrl || '',
model: parsed.model || 'gpt-4o-mini',
hasKey,
keyLast4: parsed.keyLast4 || null
};
}
async saveLlmSettings(hashedUserId, payload) {
const user = await this.getUserByHashedId(hashedUserId);
const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
if (!settingsType || !apiKeyType) {
throw new Error(
'LLM-Einstellungstypen fehlen (languageAssistant / llm_settings / llm_api_key). initializeSettings & initializeTypes ausführen.'
);
}
const settingsRow = await UserParam.findOne({
where: { userId: user.id, paramTypeId: settingsType.id }
});
let parsed = {};
if (settingsRow?.value) {
try {
parsed = JSON.parse(settingsRow.value);
} catch {
parsed = {};
}
}
const { apiKey, clearKey, baseUrl, model, enabled } = payload;
if (clearKey) {
const keyRow = await UserParam.findOne({
where: { userId: user.id, paramTypeId: apiKeyType.id }
});
if (keyRow) {
await keyRow.destroy();
}
delete parsed.keyLast4;
} else if (apiKey !== undefined && String(apiKey).trim() !== '') {
const plain = String(apiKey).trim();
parsed.keyLast4 = plain.length >= 4 ? plain.slice(-4) : plain;
const [keyRow, keyCreated] = await UserParam.findOrCreate({
where: { userId: user.id, paramTypeId: apiKeyType.id },
defaults: {
userId: user.id,
paramTypeId: apiKeyType.id,
value: plain
}
});
if (!keyCreated) {
await keyRow.update({ value: plain });
}
}
if (baseUrl !== undefined) {
parsed.baseUrl = String(baseUrl).trim();
}
if (model !== undefined) {
parsed.model = String(model).trim() || 'gpt-4o-mini';
}
if (enabled !== undefined) {
parsed.enabled = Boolean(enabled);
}
if (!parsed.model) {
parsed.model = 'gpt-4o-mini';
}
const jsonStr = JSON.stringify(parsed);
const [metaRow, metaCreated] = await UserParam.findOrCreate({
where: { userId: user.id, paramTypeId: settingsType.id },
defaults: {
userId: user.id,
paramTypeId: settingsType.id,
value: jsonStr
}
});
if (!metaCreated) {
await metaRow.update({ value: jsonStr });
}
return { success: true };
}
}
export default new SettingsService();

View File

@@ -29,6 +29,126 @@ export default class VocabService {
.replace(/\s+/g, ' ');
}
_normalizeTextAnswer(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/[.,!?;:¿¡"]/g, '')
.replace(/\s+/g, ' ');
}
_normalizeStringList(value) {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((entry) => String(entry || '').trim())
.filter(Boolean);
}
if (typeof value === 'string') {
return value
.split(/\r?\n|;/)
.map((entry) => entry.trim())
.filter(Boolean);
}
return [];
}
_normalizeStructuredList(value, keys = ['title', 'text']) {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((entry) => {
if (typeof entry === 'string') {
return { title: '', text: entry.trim() };
}
if (!entry || typeof entry !== 'object') return null;
const normalized = {};
keys.forEach((key) => {
if (entry[key] !== undefined && entry[key] !== null) {
normalized[key] = String(entry[key]).trim();
}
});
return Object.keys(normalized).length > 0 ? normalized : null;
})
.filter(Boolean);
}
return [];
}
_buildLessonDidactics(plainLesson) {
const grammarExercises = Array.isArray(plainLesson.grammarExercises) ? plainLesson.grammarExercises : [];
const grammarExplanations = [];
const patterns = [];
const speakingPrompts = [];
grammarExercises.forEach((exercise) => {
const questionData = typeof exercise.questionData === 'string'
? JSON.parse(exercise.questionData)
: (exercise.questionData || {});
if (exercise.explanation) {
grammarExplanations.push({
title: exercise.title || '',
text: exercise.explanation
});
}
const patternCandidates = [
questionData.pattern,
questionData.exampleSentence,
questionData.modelAnswer,
questionData.promptSentence
].filter(Boolean);
patternCandidates.forEach((candidate) => {
patterns.push(String(candidate).trim());
});
if (questionData.type === 'reading_aloud' || questionData.type === 'speaking_from_memory') {
speakingPrompts.push({
title: exercise.title || '',
prompt: questionData.question || questionData.text || '',
cue: questionData.expectedText || '',
keywords: Array.isArray(questionData.keywords) ? questionData.keywords : []
});
}
});
const uniqueGrammarExplanations = grammarExplanations.filter((item, index, list) => {
const signature = `${item.title}::${item.text}`;
return list.findIndex((entry) => `${entry.title}::${entry.text}` === signature) === index;
});
const uniquePatterns = [...new Set(patterns.map((item) => String(item || '').trim()).filter(Boolean))];
const learningGoals = this._normalizeStringList(plainLesson.learningGoals);
const corePatterns = this._normalizeStringList(plainLesson.corePatterns);
const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']);
const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']);
const practicalTasks = this._normalizeStructuredList(plainLesson.practicalTasks, ['title', 'text']);
return {
learningGoals: learningGoals.length > 0
? learningGoals
: [
'Die Schlüsselausdrücke der Lektion verstehen und wiedererkennen.',
'Ein bis zwei Satzmuster aktiv anwenden.',
'Kurze Sätze oder Mini-Dialoge zum Thema selbst bilden.'
],
corePatterns: corePatterns.length > 0 ? corePatterns : uniquePatterns.slice(0, 5),
grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4),
speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4),
practicalTasks: practicalTasks.length > 0
? practicalTasks
: [
{
title: 'Mini-Anwendung',
text: 'Formuliere zwei bis drei eigene Sätze oder einen kurzen Dialog mit dem Muster dieser Lektion.'
}
]
};
}
async _getLanguageAccess(userId, languageId) {
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
@@ -895,15 +1015,7 @@ export default class VocabService {
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
}
console.log(`[getLesson] Lektion ${lessonId} geladen:`, {
id: plainLesson.id,
title: plainLesson.title,
lessonType: plainLesson.lessonType,
exerciseCount: plainLesson.grammarExercises ? plainLesson.grammarExercises.length : 0,
reviewLessonsCount: plainLesson.reviewLessons ? plainLesson.reviewLessons.length : 0,
reviewVocabExercisesCount: plainLesson.reviewVocabExercises ? plainLesson.reviewVocabExercises.length : 0,
previousLessonExercisesCount: plainLesson.previousLessonExercises ? plainLesson.previousLessonExercises.length : 0
});
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
return plainLesson;
}
@@ -975,7 +1087,7 @@ export default class VocabService {
return exercises.map(e => e.get({ plain: true }));
}
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
@@ -1019,6 +1131,11 @@ export default class VocabService {
lessonType: lessonType || 'vocab',
audioUrl: audioUrl || null,
culturalNotes: culturalNotes || null,
learningGoals: this._normalizeStringList(learningGoals),
corePatterns: this._normalizeStringList(corePatterns),
grammarFocus: this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']),
speakingPrompts: this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']),
practicalTasks: this._normalizeStructuredList(practicalTasks, ['title', 'text']),
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
@@ -1027,7 +1144,7 @@ export default class VocabService {
return lesson.get({ plain: true });
}
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
@@ -1054,6 +1171,11 @@ export default class VocabService {
if (lessonType !== undefined) updates.lessonType = lessonType;
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
if (learningGoals !== undefined) updates.learningGoals = this._normalizeStringList(learningGoals);
if (corePatterns !== undefined) updates.corePatterns = this._normalizeStringList(corePatterns);
if (grammarFocus !== undefined) updates.grammarFocus = this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']);
if (speakingPrompts !== undefined) updates.speakingPrompts = this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']);
if (practicalTasks !== undefined) updates.practicalTasks = this._normalizeStructuredList(practicalTasks, ['title', 'text']);
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
@@ -1450,6 +1572,15 @@ export default class VocabService {
correctAnswer = questionData.expectedText || questionData.text || '';
alternatives = questionData.keywords || [];
}
else if (questionData.type === 'sentence_building' || questionData.type === 'dialog_completion' || questionData.type === 'situational_response' || questionData.type === 'pattern_drill') {
const rawCorrect = answerData.correct ?? answerData.correctAnswer ?? answerData.answers ?? answerData.modelAnswer;
if (Array.isArray(rawCorrect)) {
correctAnswer = rawCorrect.join(' / ');
} else {
correctAnswer = rawCorrect || questionData.modelAnswer || '';
}
alternatives = answerData.alternatives || questionData.keywords || [];
}
// Fallback: Versuche correct oder correctAnswer
else {
correctAnswer = Array.isArray(answerData.correct)
@@ -1531,10 +1662,9 @@ export default class VocabService {
// Für Reading Aloud: userAnswer ist der erkannte Text (String)
// Vergleiche mit dem erwarteten Text aus questionData.text
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') {
const normalize = (str) => String(str || '').trim().toLowerCase().replace(/[.,!?;:]/g, '');
const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
const normalizedExpected = normalize(expectedText);
const normalizedUser = normalize(userAnswer);
const normalizedExpected = this._normalizeTextAnswer(expectedText);
const normalizedUser = this._normalizeTextAnswer(userAnswer);
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
if (parsedQuestionData.type === 'reading_aloud') {
@@ -1550,16 +1680,33 @@ export default class VocabService {
return normalizedUser === normalizedExpected;
}
// Prüfe ob alle Schlüsselwörter vorhanden sind
return keywords.every(keyword => normalizedUser.includes(normalize(keyword)));
return keywords.every(keyword => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
}
}
if (parsedQuestionData.type === 'sentence_building' || parsedQuestionData.type === 'dialog_completion' || parsedQuestionData.type === 'situational_response' || parsedQuestionData.type === 'pattern_drill') {
const candidateAnswers = parsedAnswerData.correct ?? parsedAnswerData.correctAnswer ?? parsedAnswerData.answers ?? parsedAnswerData.modelAnswer ?? [];
const normalizedUser = this._normalizeTextAnswer(userAnswer);
const answers = Array.isArray(candidateAnswers) ? candidateAnswers : [candidateAnswers];
if (parsedQuestionData.type === 'situational_response') {
const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || [];
if (keywords.length > 0) {
return keywords.every((keyword) => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
}
}
return answers
.map((answer) => this._normalizeTextAnswer(answer))
.filter(Boolean)
.some((answer) => answer === normalizedUser);
}
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
const normalize = (str) => String(str || '').trim().toLowerCase();
const correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
const normalizedUserAnswer = normalize(userAnswer);
return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer);
const normalizedUserAnswer = this._normalizeTextAnswer(userAnswer);
return correctAnswersArray.some(correct => this._normalizeTextAnswer(correct) === normalizedUserAnswer);
}
async getGrammarExerciseProgress(hashedUserId, lessonId) {
@@ -1638,5 +1785,3 @@ export default class VocabService {
return { success: true };
}
}