KI-Prompts unter Vorlagen editierbar statt hardcoded

Rolle, Tonfall und Regeln der KI (Unterlagen, Chat, E-Mail-Antwort,
E-Mail-Absage, gemeinsame Stilregeln) lagen fest im Code. Sie liegen jetzt
als Defaults in lib/prompts.js und lassen sich auf der Vorlagen-Seite je
Prompt anpassen und wieder zuruecksetzen.

Nur die System-Prompts sind editierbar. Die User-Prompts tragen das
JSON-Skeleton, gegen das die Antwort geparst wird - ein Tippfehler dort
wuerde die Generierung lahmlegen, also bleiben sie im Code.

Gespeichert wird nur, was abweicht: ein Override ist eine Zeile in der neuen
Tabelle `prompts`, "Zuruecksetzen" loescht sie. Damit bleiben die Defaults im
Code die Wahrheit und wandern bei Updates automatisch mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 00:09:03 +02:00
co-authored by Claude Opus 4.8
parent 0a0b0b4724
commit 65a5943993
5 changed files with 286 additions and 49 deletions
+70 -2
View File
@@ -29,6 +29,7 @@ const multer = require('multer');
const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents');
const chat = require('./lib/chat');
const promptStore = require('./lib/prompts');
const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi');
@@ -425,6 +426,19 @@ async function setState(key, value) {
);
}
// The user's KI prompt overrides as { key: text }. Keys without a row keep the
// default from lib/prompts.js. Read fresh on every generation so an edit takes
// effect immediately, without a restart.
async function loadPrompts() {
try {
const rows = await dbAll('SELECT key, inhalt FROM prompts');
return Object.fromEntries(rows.map((r) => [r.key, r.inhalt]));
} catch (e) {
console.error('Konnte Prompts nicht laden, nutze Standardtexte:', e.message);
return {};
}
}
// Match an incoming message to an application: first via In-Reply-To/References
// pointing at one of our sent messages, then by sender = a previous recipient.
async function matchBewerbung(msg) {
@@ -583,6 +597,7 @@ async function runGeneration(bewerbungId, options = {}) {
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC');
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
const prompts = await loadPrompts();
// Only the explicitly selected extra attachments are enclosed (default: none).
const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : [];
@@ -598,6 +613,7 @@ async function runGeneration(bewerbungId, options = {}) {
},
basisDokumente,
settings,
prompts,
// Names of the selected attachments so the cover letter (and the LLM) lists
// exactly these under "Anlagen".
zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname),
@@ -940,6 +956,17 @@ function initializeDatabase() {
`, () => {});
db.run('CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)', () => {});
// Overridden KI system prompts. Only prompts the user actually edited
// are stored; everything else falls back to the defaults in
// lib/prompts.js, so "Zurücksetzen" is a plain DELETE.
db.run(`
CREATE TABLE IF NOT EXISTS prompts (
key TEXT PRIMARY KEY,
inhalt TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, () => {});
db.run(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -1578,6 +1605,7 @@ initializeDatabase().then(() => {
incoming: { from: orig.from_addr, subject: orig.subject, text: emailPlainText(orig) },
job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
settings,
prompts: await loadPrompts(),
hinweise: String(req.body.hinweise || ''),
typ: String(req.body.typ || 'antwort'),
});
@@ -1883,6 +1911,7 @@ initializeDatabase().then(() => {
res.render('vorlagen', {
basisDokumente,
basisAnhaenge,
prompts: promptStore.list(await loadPrompts()),
hasSignatur: Boolean(currentSignaturFile()),
hasFoto: Boolean(currentFotoFile()),
basisTypOptions: BASIS_TYP_OPTIONS,
@@ -1895,6 +1924,44 @@ initializeDatabase().then(() => {
}
});
// ----- Editable KI prompts -----
// Registered before /vorlagen/:id so the generic base-document handlers don't
// swallow these paths. The prompt text is stored raw (no HTML escaping): it is
// sent to the LLM, never rendered as markup — the views escape it on output.
// Save an overridden prompt. Empty text = fall back to the default.
app.post('/vorlagen/prompts/:key', async (req, res) => {
try {
const { key } = req.params;
if (!promptStore.isKnownKey(key)) return res.status(404).send('Unbekannter Prompt');
const inhalt = String(req.body.inhalt || '').trim();
if (!inhalt || inhalt === promptStore.defaultText(key).trim()) {
await dbRun('DELETE FROM prompts WHERE key = ?', [key]);
} else {
await dbRun(
`INSERT INTO prompts (key, inhalt, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET inhalt = excluded.inhalt, updated_at = CURRENT_TIMESTAMP`,
[key, inhalt]
);
}
res.redirect('/vorlagen#prompts');
} catch (error) {
console.error('Error saving prompt:', error);
res.status(500).send('Serverfehler');
}
});
// Restore the default text of a prompt by dropping the override.
app.post('/vorlagen/prompts/:key/reset', async (req, res) => {
try {
await dbRun('DELETE FROM prompts WHERE key = ?', [req.params.key]);
res.redirect('/vorlagen#prompts');
} catch (error) {
console.error('Error resetting prompt:', error);
res.status(500).send('Serverfehler');
}
});
// Add a base document
app.post('/vorlagen', async (req, res) => {
try {
@@ -2431,20 +2498,21 @@ initializeDatabase().then(() => {
// Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
// assistant answer is streamed back via Server-Sent Events.
async function gatherChatContext() {
const [settings, profilRows] = await Promise.all([
const [settings, profilRows, prompts] = await Promise.all([
dbGet('SELECT name FROM settings WHERE id = 1'),
dbAll(
`SELECT inhalt FROM basis_dokumente
WHERE typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != ''
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END`
),
loadPrompts(),
]);
// Lightweight core context only: name, date and the user's profile (static,
// small). All application/appointment data is fetched on demand via tools,
// so the system prompt stays tiny regardless of how many bewerbungen exist.
const profil = (profilRows.map((r) => (r.inhalt || '').trim()).join('\n\n---\n\n')).slice(0, 1800);
const heute = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
return { heute, profil, settings: settings || {} };
return { heute, profil, prompts, settings: settings || {} };
}
// Ollama tool definitions the assistant can call to look up application data.