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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user