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:
58
mcp/language-course-server/src/llm.mjs
Normal file
58
mcp/language-course-server/src/llm.mjs
Normal 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;
|
||||
}
|
||||
302
mcp/language-course-server/src/server.mjs
Normal file
302
mcp/language-course-server/src/server.mjs
Normal 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 5–10 Ä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 (1–3 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, 1–2 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);
|
||||
Reference in New Issue
Block a user