Add language assistant features and improve encryption handling: Implement a new route and controller method for sending messages to the language assistant, enhancing user interaction within lessons. Update the encryption utility to support both base64 and hex formats for better compatibility with existing data. Enhance localization files to include new terms related to the language assistant in English, German, and Spanish, improving user experience across languages.
This commit is contained in:
@@ -55,6 +55,7 @@ class VocabController {
|
||||
this.getGrammarExerciseProgress = this._wrapWithUser((userId, req) => this.service.getGrammarExerciseProgress(userId, req.params.lessonId));
|
||||
this.updateGrammarExercise = this._wrapWithUser((userId, req) => this.service.updateGrammarExercise(userId, req.params.exerciseId, req.body));
|
||||
this.deleteGrammarExercise = this._wrapWithUser((userId, req) => this.service.deleteGrammarExercise(userId, req.params.exerciseId));
|
||||
this.sendLessonAssistantMessage = this._wrapWithUser((userId, req) => this.service.sendLessonAssistantMessage(userId, req.params.lessonId, req.body), { successStatus: 201 });
|
||||
}
|
||||
|
||||
_wrapWithUser(fn, { successStatus = 200 } = {}) {
|
||||
@@ -77,4 +78,3 @@ class VocabController {
|
||||
|
||||
export default VocabController;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE community.user_param
|
||||
ALTER COLUMN value TYPE TEXT;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE community.user_param
|
||||
ALTER COLUMN value TYPE VARCHAR(255);
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -14,7 +14,7 @@ const UserParam = sequelize.define('user_param', {
|
||||
allowNull: false
|
||||
},
|
||||
value: {
|
||||
type: DataTypes.STRING,
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
set(value) {
|
||||
if (value) {
|
||||
|
||||
@@ -48,6 +48,7 @@ router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress);
|
||||
|
||||
// Grammar Exercises
|
||||
router.get('/grammar/exercise-types', vocabController.getExerciseTypes);
|
||||
router.post('/lessons/:lessonId/assistant', vocabController.sendLessonAssistantMessage);
|
||||
router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise);
|
||||
router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson);
|
||||
router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress);
|
||||
@@ -58,4 +59,3 @@ router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExe
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@ import VocabCourseProgress from '../models/community/vocab_course_progress.js';
|
||||
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
|
||||
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
|
||||
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
|
||||
import UserParamType from '../models/type/user_param.js';
|
||||
import UserParam from '../models/community/user_param.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import { notifyUser } from '../utils/socket.js';
|
||||
import { Op } from 'sequelize';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
|
||||
export default class VocabService {
|
||||
async _getUserByHashedId(hashedUserId) {
|
||||
@@ -22,6 +25,117 @@ export default class VocabService {
|
||||
return user;
|
||||
}
|
||||
|
||||
async _getUserLlmConfig(userId) {
|
||||
const [settingsType, apiKeyType] = await Promise.all([
|
||||
UserParamType.findOne({ where: { description: 'llm_settings' } }),
|
||||
UserParamType.findOne({ where: { description: 'llm_api_key' } })
|
||||
]);
|
||||
|
||||
if (!settingsType || !apiKeyType) {
|
||||
return {
|
||||
enabled: false,
|
||||
baseUrl: '',
|
||||
model: 'gpt-4o-mini',
|
||||
hasKey: false,
|
||||
apiKey: null,
|
||||
configured: false
|
||||
};
|
||||
}
|
||||
|
||||
const [settingsRow, keyRow] = await Promise.all([
|
||||
UserParam.findOne({ where: { userId, paramTypeId: settingsType.id } }),
|
||||
UserParam.findOne({ where: { userId, paramTypeId: apiKeyType.id } })
|
||||
]);
|
||||
|
||||
let parsed = {};
|
||||
if (settingsRow?.value) {
|
||||
try {
|
||||
parsed = JSON.parse(settingsRow.value);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedKey = keyRow?.value ? decrypt(keyRow.value) : null;
|
||||
const hasKey = Boolean(decryptedKey && String(decryptedKey).trim());
|
||||
const enabled = parsed.enabled !== false;
|
||||
const baseUrl = String(parsed.baseUrl || '').trim();
|
||||
|
||||
return {
|
||||
enabled,
|
||||
baseUrl,
|
||||
model: String(parsed.model || 'gpt-4o-mini').trim() || 'gpt-4o-mini',
|
||||
hasKey,
|
||||
apiKey: hasKey ? decryptedKey : null,
|
||||
configured: enabled && (hasKey || Boolean(baseUrl))
|
||||
};
|
||||
}
|
||||
|
||||
_sanitizeAssistantHistory(history) {
|
||||
if (!Array.isArray(history)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return history
|
||||
.slice(-8)
|
||||
.map((entry) => ({
|
||||
role: entry?.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: String(entry?.content || '').trim()
|
||||
}))
|
||||
.filter((entry) => entry.content);
|
||||
}
|
||||
|
||||
_buildLessonAssistantSystemPrompt(lesson, mode = 'practice') {
|
||||
const didactics = lesson?.didactics || {};
|
||||
const learningGoals = Array.isArray(didactics.learningGoals) ? didactics.learningGoals : [];
|
||||
const corePatterns = Array.isArray(didactics.corePatterns) ? didactics.corePatterns : [];
|
||||
const speakingPrompts = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts : [];
|
||||
const practicalTasks = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks : [];
|
||||
|
||||
const modeDirectives = {
|
||||
explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.',
|
||||
practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.',
|
||||
correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.'
|
||||
};
|
||||
|
||||
return [
|
||||
'Du bist ein didaktischer Sprachassistent innerhalb eines Sprachkurses.',
|
||||
'Antworte auf Deutsch, aber verwende die Zielsprache der Lektion aktiv in Beispielen und Mini-Dialogen.',
|
||||
modeDirectives[mode] || modeDirectives.practice,
|
||||
'Halte Antworten kompakt, praxisnah und auf diese Lektion fokussiert.',
|
||||
`Kurs: ${lesson?.course?.title || 'Unbekannter Kurs'}`,
|
||||
`Lektion: ${lesson?.title || 'Unbekannte Lektion'}`,
|
||||
lesson?.description ? `Beschreibung: ${lesson.description}` : '',
|
||||
learningGoals.length ? `Lernziele: ${learningGoals.join(' | ')}` : '',
|
||||
corePatterns.length ? `Kernmuster: ${corePatterns.join(' | ')}` : '',
|
||||
speakingPrompts.length
|
||||
? `Sprechaufträge: ${speakingPrompts.map((item) => item.prompt || item.title || '').filter(Boolean).join(' | ')}`
|
||||
: '',
|
||||
practicalTasks.length
|
||||
? `Praxisaufgaben: ${practicalTasks.map((item) => item.text || item.title || '').filter(Boolean).join(' | ')}`
|
||||
: '',
|
||||
'Wenn der Nutzer eine Formulierung versucht, korrigiere sie präzise und gib eine verbesserte Version.'
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
_extractAssistantContent(responseData) {
|
||||
const rawContent = responseData?.choices?.[0]?.message?.content;
|
||||
if (typeof rawContent === 'string') {
|
||||
return rawContent.trim();
|
||||
}
|
||||
if (Array.isArray(rawContent)) {
|
||||
return rawContent
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (item?.type === 'text') return item.text || '';
|
||||
return '';
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
_normalizeLexeme(text) {
|
||||
return String(text || '')
|
||||
.trim()
|
||||
@@ -1019,6 +1133,107 @@ export default class VocabService {
|
||||
return plainLesson;
|
||||
}
|
||||
|
||||
async sendLessonAssistantMessage(hashedUserId, lessonId, payload = {}) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const lesson = await this.getLesson(hashedUserId, lessonId);
|
||||
const config = await this._getUserLlmConfig(user.id);
|
||||
|
||||
if (!config.enabled) {
|
||||
const err = new Error('Der Sprachassistent ist in deinen Einstellungen derzeit deaktiviert.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!config.configured) {
|
||||
const err = new Error('Der Sprachassistent ist noch nicht eingerichtet. Bitte hinterlege zuerst Modell und API-Zugang in den Einstellungen.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = String(payload?.message || '').trim();
|
||||
if (!message) {
|
||||
const err = new Error('Bitte gib eine Nachricht für den Sprachassistenten ein.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const mode = ['explain', 'practice', 'correct'].includes(payload?.mode) ? payload.mode : 'practice';
|
||||
const history = this._sanitizeAssistantHistory(payload?.history);
|
||||
const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
|
||||
const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (config.apiKey) {
|
||||
headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
temperature: 0.7,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: this._buildLessonAssistantSystemPrompt(lesson, mode)
|
||||
},
|
||||
...history,
|
||||
{
|
||||
role: 'user',
|
||||
content: message
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
error?.name === 'AbortError'
|
||||
? 'Der Sprachassistent hat zu lange für eine Antwort gebraucht.'
|
||||
: 'Der Sprachassistent konnte nicht erreicht werden.'
|
||||
);
|
||||
err.status = 502;
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
let responseData = null;
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch {
|
||||
responseData = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const messageFromApi = responseData?.error?.message || responseData?.message || 'Der Sprachassistent hat die Anfrage abgelehnt.';
|
||||
const err = new Error(messageFromApi);
|
||||
err.status = response.status || 502;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const reply = this._extractAssistantContent(responseData);
|
||||
if (!reply) {
|
||||
const err = new Error('Der Sprachassistent hat keine verwertbare Antwort geliefert.');
|
||||
err.status = 502;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
reply,
|
||||
model: responseData?.model || config.model,
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
|
||||
*/
|
||||
|
||||
2
backend/sql/expand_user_param_value_to_text.sql
Normal file
2
backend/sql/expand_user_param_value_to_text.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE community.user_param
|
||||
ALTER COLUMN value TYPE TEXT;
|
||||
@@ -14,19 +14,32 @@ export const generateIv = () => {
|
||||
|
||||
export const encrypt = (text) => {
|
||||
const cipher = crypto.createCipheriv(algorithm, key, null);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
let encrypted = cipher.update(text, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
return encrypted;
|
||||
};
|
||||
|
||||
export const decrypt = (text) => {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = String(text);
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, null);
|
||||
let decrypted = decipher.update(text, 'hex', 'utf8');
|
||||
let decrypted = decipher.update(input, 'base64', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return null;
|
||||
} catch (base64Error) {
|
||||
try {
|
||||
// Rueckwaertskompatibel fuer bereits gespeicherte Hex-Werte.
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, null);
|
||||
let decrypted = decipher.update(input, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (hexError) {
|
||||
console.log(hexError);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user