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
+5 -11
View File
@@ -10,6 +10,8 @@
// Unlike lib/documents.js, no JSON output schema is enforced — the model just // Unlike lib/documents.js, no JSON output schema is enforced — the model just
// replies as text, token by token, so the UI can render progressively. // replies as text, token by token, so the UI can render progressively.
const promptStore = require('./prompts');
const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, '');
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b';
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000); const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000);
@@ -184,17 +186,9 @@ function buildContextPrompt(ctx) {
const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber'; const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber';
const heute = (ctx && ctx.heute) || ''; const heute = (ctx && ctx.heute) || '';
const parts = []; const parts = [];
parts.push( // The assistant's role/tone is editable (Vorlagen page); the sections below
'Du bist ein hilfreicher, deutschsprachiger Bewerbungs-Assistent für ' + name + '. ' + // describe the runtime wiring (tools, date, profile) and stay in code.
'Du beantwortest Fragen zu laufenden Bewerbungen, hilfst beim Formulieren von ' + parts.push(promptStore.get(ctx && ctx.prompts, 'chat', { name }));
'Antworten, beim Vorbereiten auf Gespräche und beim Überblick über den Status. ' +
'Antworte natürlich, knapp und auf Deutsch im lateinischen Alphabet. ' +
'Erfinde KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen, Firmen). ' +
'Wenn eine Angabe nötig ist, die du nicht weißt, setze einen klar erkennbaren ' +
'Platzhalter in eckigen Klammern. Verwende nur den einfachen Bindestrich "-". ' +
'Fasse Stellenbeschreibungen verständlich zusammen, statt sie wörtlich ' +
'wiederzugeben, und beziehe sie auf das Profil des Bewerbers.'
);
parts.push( parts.push(
'# Werkzeuge\n' + '# Werkzeuge\n' +
'Du hast Werkzeuge, um Bewerbungsdaten nachzuschlagen. Nutze sie, sobald der ' + 'Du hast Werkzeuge, um Bewerbungsdaten nachzuschlagen. Nutze sie, sobald der ' +
+16 -36
View File
@@ -9,6 +9,7 @@
const { jsPDF } = require('jspdf'); const { jsPDF } = require('jspdf');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const promptStore = require('./prompts');
// Embedded professional typeface for both cover letter and résumé: Lato // Embedded professional typeface for both cover letter and résumé: Lato
// (SIL Open Font License, see lib/fonts/OFL.txt). The base64 TTFs are read // (SIL Open Font License, see lib/fonts/OFL.txt). The base64 TTFs are read
@@ -127,7 +128,7 @@ const OUTPUT_SCHEMA = {
required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'], required: ['headline', 'kontakt', 'anschreiben', 'email', 'lebenslauf'],
}; };
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [] }) { async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '', zusatzAnlagen = [], prompts = null }) {
const apiKey = process.env.OLLAMA_API_KEY; const apiKey = process.env.OLLAMA_API_KEY;
if (!apiKey) { if (!apiKey) {
throw new Error( throw new Error(
@@ -162,19 +163,9 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen
job.stellenbeschreibung || '(keine Beschreibung übermittelt)', job.stellenbeschreibung || '(keine Beschreibung übermittelt)',
].filter((l) => l !== null).join('\n'); ].filter((l) => l !== null).join('\n');
const system = // Role, tone and rules come from the (editable) prompt store; the JSON skeleton
'Du bist ein erfahrener Bewerbungscoach und erstellst professionelle, ' + // below stays in code because the response parsing depends on it.
'deutschsprachige Bewerbungstexte. Du passt die BEREITGESTELLTEN Basis-Unterlagen ' + const system = promptStore.get(prompts, 'unterlagen');
'des Bewerbers auf eine konkrete Stellenausschreibung an. Wichtigste Regel: Du ' +
'erfindest KEINE Fakten, Qualifikationen, Abschlüsse, Kontaktdaten oder ' +
'Berufserfahrungen. Verwende ausschließlich Informationen aus den Basis-Unterlagen. ' +
'Du darfst umformulieren, gewichten, relevante Punkte hervorheben und auf die Stelle ' +
'zuschneiden — aber nichts hinzudichten. Schreibe natürlich, konkret und ohne Floskeln, so wie ein ' +
'deutscher Muttersprachler schreibt: keine Buzzword-Paare, keine leeren Eigenschaftswörter und keine ' +
'Selbstetiketten wie "Als Muttersprachler ..."; jede Aussage aus einer konkreten Tätigkeit heraus. ' +
'Verwende AUSSCHLIESSLICH deutsche Sprache und das lateinische Alphabet. Keine englischen, ' +
'chinesischen, japanischen, koreanischen oder anderen fremdsprachigen Wörter oder Schriftzeichen - ' +
'auch nicht einzelne Zeichen oder Füllwörter.';
const skeleton = const skeleton =
`{\n` + `{\n` +
@@ -1140,8 +1131,8 @@ function hasLebenslauf(l) {
|| l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length); || l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length);
} }
async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null }) { async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null }) {
const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen }); const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts });
const headline = data.headline || job.stelle || ''; const headline = data.headline || job.stelle || '';
const header = buildHeader(settings, data.kontakt, headline); const header = buildHeader(settings, data.kontakt, headline);
@@ -1241,28 +1232,20 @@ const REPLY_SCHEMA = {
// `settings` = { name, ... }. `hinweise` = optional free-text steering. // `settings` = { name, ... }. `hinweise` = optional free-text steering.
// `typ` = 'antwort' (default) for a normal reply, or 'absage' for a polite, short // `typ` = 'antwort' (default) for a normal reply, or 'absage' for a polite, short
// rejection because the applicant accepted another position. // rejection because the applicant accepted another position.
async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise = '', typ = 'antwort' }) { async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise = '', typ = 'antwort', prompts = null }) {
const name = (settings && settings.name) || 'der Bewerber'; const name = (settings && settings.name) || 'der Bewerber';
const isAbsage = typ === 'absage'; const isAbsage = typ === 'absage';
const commonRules = // Role prompt (per type) plus the shared style rules — both editable; the task
' Wichtig: Du erfindest KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen). ' + // description below stays in code because it defines the expected JSON.
'Wenn eine konkrete Angabe nötig ist, die du nicht kennst, setze einen klar erkennbaren ' + const commonRules = promptStore.get(prompts, 'email_stil');
'Platzhalter in eckigen Klammern, z. B. "[Terminvorschlag einfügen]". Schreibe natürlich, ' + // Joined with a space so neither part has to carry padding of its own — the
'knapp und verbindlich, wie ein deutscher Muttersprachler - ohne leere Floskeln, ohne ' + // user edits these as plain paragraphs.
'Buzzword-Paare und ohne Selbstetiketten wie "Als Muttersprachler ...". Verwende ausschließlich ' + const withRules = (rolle) => [rolle, commonRules].filter((s) => s && s.trim()).join(' ');
'deutsche Sprache und das lateinische Alphabet - keine fremdsprachigen Wörter oder Schriftzeichen.';
let system, user, betreffDefault; let system, user, betreffDefault;
if (isAbsage) { if (isAbsage) {
system = system = withRules(promptStore.get(prompts, 'email_absage', { name }));
'Du bist ' + name + ' und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Absage-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
'Du teilst mit, dass du dich entschieden hast, eine andere Stelle anzunehmen, und ' +
'bedankst dich für die Zeit, das Gespräch und das Vertrauen. Halte die E-Mail bewusst ' +
'kurz: ein bis zwei kurze Absätze genügen - keine langen Erklärungen, keine Rechtfertigung. ' +
'Bleibe wertschätzend und bestimmt zugleich; nenne keine Gründe im Detail und erfinde keine ' +
'konkreten Namen anderer Unternehmen.' + commonRules;
user = user =
`# Kontext der Bewerbung\n` + `# Kontext der Bewerbung\n` +
`Unternehmen: ${job.firma || '-'}\n` + `Unternehmen: ${job.firma || '-'}\n` +
@@ -1284,10 +1267,7 @@ async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise
`Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`; `Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`;
betreffDefault = 'Re: ' + (incoming.subject || ('Absage zur Bewerbung als ' + (job.stelle || ''))); betreffDefault = 'Re: ' + (incoming.subject || ('Absage zur Bewerbung als ' + (job.stelle || '')));
} else { } else {
system = system = withRules(promptStore.get(prompts, 'email_antwort', { name }));
'Du bist ' + name + ' und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Antwort-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
'Du antwortest konkret auf die eingegangene Nachricht.' + commonRules;
user = user =
`# Kontext der Bewerbung\n` + `# Kontext der Bewerbung\n` +
`Unternehmen: ${job.firma || '-'}\n` + `Unternehmen: ${job.firma || '-'}\n` +
+138
View File
@@ -0,0 +1,138 @@
// Editable KI system prompts.
//
// Every prompt that shapes the assistant's role, tone and rules lives here as a
// default and can be overridden per installation on the Vorlagen page. Only the
// *system* prompts are editable: the user prompts carry the JSON skeleton the
// answers are parsed against, so they stay in code where a typo can't break
// generation. An override is a row in the `prompts` table; deleting the row
// restores the default (that's what "Zurücksetzen" does).
//
// Placeholders are written as {{name}} and filled by render().
const DEFAULTS = [
{
key: 'unterlagen',
titel: 'Bewerbungsunterlagen (Anschreiben & Lebenslauf)',
beschreibung:
'Rolle und Regeln für die Generierung der Bewerbungsunterlagen aus deinen Basis-Unterlagen. ' +
'Der Aufbau der Dokumente (JSON-Struktur, Felder) ist fest verdrahtet und wird hiervon nicht berührt.',
platzhalter: [],
inhalt:
'Du bist ein erfahrener Bewerbungscoach und erstellst professionelle, ' +
'deutschsprachige Bewerbungstexte. Du passt die BEREITGESTELLTEN Basis-Unterlagen ' +
'des Bewerbers auf eine konkrete Stellenausschreibung an. Wichtigste Regel: Du ' +
'erfindest KEINE Fakten, Qualifikationen, Abschlüsse, Kontaktdaten oder ' +
'Berufserfahrungen. Verwende ausschließlich Informationen aus den Basis-Unterlagen. ' +
'Du darfst umformulieren, gewichten, relevante Punkte hervorheben und auf die Stelle ' +
'zuschneiden — aber nichts hinzudichten. Schreibe natürlich, konkret und ohne Floskeln, so wie ein ' +
'deutscher Muttersprachler schreibt: keine Buzzword-Paare, keine leeren Eigenschaftswörter und keine ' +
'Selbstetiketten wie "Als Muttersprachler ..."; jede Aussage aus einer konkreten Tätigkeit heraus. ' +
'Verwende AUSSCHLIESSLICH deutsche Sprache und das lateinische Alphabet. Keine englischen, ' +
'chinesischen, japanischen, koreanischen oder anderen fremdsprachigen Wörter oder Schriftzeichen - ' +
'auch nicht einzelne Zeichen oder Füllwörter.',
},
{
key: 'chat',
titel: 'KI-Chat (Bewerbungs-Assistent)',
beschreibung:
'Grundhaltung des Chat-Assistenten. Die Hinweise zu Werkzeugen, Datum und deinem Profil ' +
'werden automatisch ergänzt.',
platzhalter: ['name'],
inhalt:
'Du bist ein hilfreicher, deutschsprachiger Bewerbungs-Assistent für {{name}}. ' +
'Du beantwortest Fragen zu laufenden Bewerbungen, hilfst beim Formulieren von ' +
'Antworten, beim Vorbereiten auf Gespräche und beim Überblick über den Status. ' +
'Antworte natürlich, knapp und auf Deutsch im lateinischen Alphabet. ' +
'Erfinde KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen, Firmen). ' +
'Wenn eine Angabe nötig ist, die du nicht weißt, setze einen klar erkennbaren ' +
'Platzhalter in eckigen Klammern. Verwende nur den einfachen Bindestrich "-". ' +
'Fasse Stellenbeschreibungen verständlich zusammen, statt sie wörtlich ' +
'wiederzugeben, und beziehe sie auf das Profil des Bewerbers.',
},
{
key: 'email_antwort',
titel: 'E-Mail: Antwort auf eine Nachricht',
beschreibung: 'Rolle für den KI-Entwurf einer Antwort-E-Mail an ein Unternehmen.',
platzhalter: ['name'],
inhalt:
'Du bist {{name}} und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Antwort-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
'Du antwortest konkret auf die eingegangene Nachricht.',
},
{
key: 'email_absage',
titel: 'E-Mail: Absage (andere Stelle angenommen)',
beschreibung: 'Rolle für den KI-Entwurf einer Absage an ein Unternehmen.',
platzhalter: ['name'],
inhalt:
'Du bist {{name}} und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Absage-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
'Du teilst mit, dass du dich entschieden hast, eine andere Stelle anzunehmen, und ' +
'bedankst dich für die Zeit, das Gespräch und das Vertrauen. Halte die E-Mail bewusst ' +
'kurz: ein bis zwei kurze Absätze genügen - keine langen Erklärungen, keine Rechtfertigung. ' +
'Bleibe wertschätzend und bestimmt zugleich; nenne keine Gründe im Detail und erfinde keine ' +
'konkreten Namen anderer Unternehmen.',
},
{
key: 'email_stil',
titel: 'E-Mail: gemeinsame Stilregeln',
beschreibung:
'Wird an beide E-Mail-Prompts oben angehängt. Hier stehen die Regeln, die für jede ' +
'generierte E-Mail gelten sollen.',
platzhalter: [],
inhalt:
'Wichtig: Du erfindest KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen). ' +
'Wenn eine konkrete Angabe nötig ist, die du nicht kennst, setze einen klar erkennbaren ' +
'Platzhalter in eckigen Klammern, z. B. "[Terminvorschlag einfügen]". Schreibe natürlich, ' +
'knapp und verbindlich, wie ein deutscher Muttersprachler - ohne leere Floskeln, ohne ' +
'Buzzword-Paare und ohne Selbstetiketten wie "Als Muttersprachler ...". Verwende ausschließlich ' +
'deutsche Sprache und das lateinische Alphabet - keine fremdsprachigen Wörter oder Schriftzeichen.',
},
];
const BY_KEY = new Map(DEFAULTS.map((p) => [p.key, p]));
// Default text of a prompt, or '' for an unknown key.
function defaultText(key) {
const p = BY_KEY.get(key);
return p ? p.inhalt : '';
}
function isKnownKey(key) {
return BY_KEY.has(key);
}
// Substitute {{placeholders}}. Unknown placeholders are left untouched so a typo
// in the UI shows up in the output instead of silently emptying the sentence.
function render(text, vars = {}) {
return String(text == null ? '' : text).replace(/\{\{(\w+)\}\}/g, (match, name) =>
Object.prototype.hasOwnProperty.call(vars, name) && vars[name] ? String(vars[name]) : match
);
}
// Look a prompt up in the overrides map (key -> text), falling back to the
// default, and fill its placeholders. `prompts` may be undefined — callers that
// don't pass any overrides simply get the defaults.
function get(prompts, key, vars = {}) {
const override = prompts && typeof prompts[key] === 'string' ? prompts[key].trim() : '';
return render(override || defaultText(key), vars);
}
// The list rendered on the Vorlagen page: every prompt with its current text and
// whether it deviates from the default.
function list(overrides = {}) {
return DEFAULTS.map((p) => {
const override = typeof overrides[p.key] === 'string' ? overrides[p.key].trim() : '';
return {
key: p.key,
titel: p.titel,
beschreibung: p.beschreibung,
platzhalter: p.platzhalter,
inhalt: override || p.inhalt,
standard: p.inhalt,
angepasst: Boolean(override && override !== p.inhalt.trim()),
};
});
}
module.exports = { DEFAULTS, defaultText, isKnownKey, render, get, list };
+70 -2
View File
@@ -29,6 +29,7 @@ const multer = require('multer');
const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents'); const { generateApplicationDocuments, generateEmailReply } = require('./lib/documents');
const chat = require('./lib/chat'); const chat = require('./lib/chat');
const promptStore = require('./lib/prompts');
const mailer = require('./lib/mailer'); const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api'); const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi'); 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 // 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. // pointing at one of our sent messages, then by sender = a previous recipient.
async function matchBewerbung(msg) { 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 basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge 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 settings = await dbGet('SELECT * FROM settings WHERE id = 1');
const prompts = await loadPrompts();
// Only the explicitly selected extra attachments are enclosed (default: none). // Only the explicitly selected extra attachments are enclosed (default: none).
const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : []; const anlagenIds = Array.isArray(options.anlagenIds) ? options.anlagenIds.map(Number) : [];
@@ -598,6 +613,7 @@ async function runGeneration(bewerbungId, options = {}) {
}, },
basisDokumente, basisDokumente,
settings, settings,
prompts,
// Names of the selected attachments so the cover letter (and the LLM) lists // Names of the selected attachments so the cover letter (and the LLM) lists
// exactly these under "Anlagen". // exactly these under "Anlagen".
zusatzAnlagen: selectedAnhaenge.map((a) => a.name || a.dateiname), 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)', () => {}); 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(` db.run(`
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -1578,6 +1605,7 @@ initializeDatabase().then(() => {
incoming: { from: orig.from_addr, subject: orig.subject, text: emailPlainText(orig) }, incoming: { from: orig.from_addr, subject: orig.subject, text: emailPlainText(orig) },
job: { firma: bewerbung.firma, stelle: bewerbung.stelle }, job: { firma: bewerbung.firma, stelle: bewerbung.stelle },
settings, settings,
prompts: await loadPrompts(),
hinweise: String(req.body.hinweise || ''), hinweise: String(req.body.hinweise || ''),
typ: String(req.body.typ || 'antwort'), typ: String(req.body.typ || 'antwort'),
}); });
@@ -1883,6 +1911,7 @@ initializeDatabase().then(() => {
res.render('vorlagen', { res.render('vorlagen', {
basisDokumente, basisDokumente,
basisAnhaenge, basisAnhaenge,
prompts: promptStore.list(await loadPrompts()),
hasSignatur: Boolean(currentSignaturFile()), hasSignatur: Boolean(currentSignaturFile()),
hasFoto: Boolean(currentFotoFile()), hasFoto: Boolean(currentFotoFile()),
basisTypOptions: BASIS_TYP_OPTIONS, 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 // Add a base document
app.post('/vorlagen', async (req, res) => { app.post('/vorlagen', async (req, res) => {
try { try {
@@ -2431,20 +2498,21 @@ initializeDatabase().then(() => {
// Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the // Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
// assistant answer is streamed back via Server-Sent Events. // assistant answer is streamed back via Server-Sent Events.
async function gatherChatContext() { async function gatherChatContext() {
const [settings, profilRows] = await Promise.all([ const [settings, profilRows, prompts] = await Promise.all([
dbGet('SELECT name FROM settings WHERE id = 1'), dbGet('SELECT name FROM settings WHERE id = 1'),
dbAll( dbAll(
`SELECT inhalt FROM basis_dokumente `SELECT inhalt FROM basis_dokumente
WHERE typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != '' WHERE typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != ''
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END` ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END`
), ),
loadPrompts(),
]); ]);
// Lightweight core context only: name, date and the user's profile (static, // Lightweight core context only: name, date and the user's profile (static,
// small). All application/appointment data is fetched on demand via tools, // small). All application/appointment data is fetched on demand via tools,
// so the system prompt stays tiny regardless of how many bewerbungen exist. // 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 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' }); 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. // Ollama tool definitions the assistant can call to look up application data.
+57
View File
@@ -251,6 +251,63 @@
</button> </button>
</form> </form>
</section> </section>
<!-- Editable KI prompts -->
<h2 id="prompts" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">KI-Prompts</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Hier steht, <em>wie</em> die KI schreibt: Rolle, Tonfall und Regeln für Unterlagen, Chat und E-Mails.
Du kannst jeden Text anpassen etwa um zu duzen, Formulierungen zu verbieten oder eine andere
Sprache zu verlangen. Der technische Aufbau (Aufbau des Lebenslaufs, Felder der E-Mail) ist fest
verdrahtet und wird davon nicht berührt, damit nichts kaputtgehen kann.
Mit <strong>Zurücksetzen</strong> holst du jederzeit den Standardtext zurück.
</p>
<section class="space-y-4 mb-8">
<% prompts.forEach(p => { %>
<details class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden" <%= p.angepasst ? 'open' : '' %>>
<summary class="cursor-pointer select-none px-6 py-4 flex items-center gap-3">
<span class="font-semibold text-gray-800 dark:text-white"><%= p.titel %></span>
<% if (p.angepasst) { %>
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300">angepasst</span>
<% } else { %>
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">Standard</span>
<% } %>
</summary>
<div class="px-6 pb-6 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3"><%= p.beschreibung %></p>
<% if (p.platzhalter && p.platzhalter.length) { %>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Platzhalter:
<% p.platzhalter.forEach(ph => { %>
<code class="px-1 rounded bg-gray-100 dark:bg-gray-700">{{<%= ph %>}}</code>
<% }) %>
wird beim Generieren automatisch ersetzt (Name aus den Einstellungen).
</p>
<% } %>
<form action="/vorlagen/prompts/<%= p.key %>" method="POST" class="space-y-3">
<textarea name="inhalt" rows="9"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-xs leading-relaxed"><%= p.inhalt %></textarea>
<div class="flex items-center gap-3">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
Speichern
</button>
<% if (p.angepasst) { %>
<button type="submit" formaction="/vorlagen/prompts/<%= p.key %>/reset"
formnovalidate
onclick="return confirm('Diesen Prompt auf den Standardtext zurücksetzen? Deine Änderungen gehen verloren.');"
class="text-sm text-gray-600 dark:text-gray-400 hover:underline">
Auf Standard zurücksetzen
</button>
<% } %>
</div>
</form>
</div>
</details>
<% }) %>
</section>
</main> </main>
<%- include('partials/footer') %> <%- include('partials/footer') %>