diff --git a/lib/chat.js b/lib/chat.js index f3df7af..a2251bd 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -10,6 +10,8 @@ // 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. +const promptStore = require('./prompts'); + const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; 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 heute = (ctx && ctx.heute) || ''; const parts = []; - parts.push( - '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.' - ); + // The assistant's role/tone is editable (Vorlagen page); the sections below + // describe the runtime wiring (tools, date, profile) and stay in code. + parts.push(promptStore.get(ctx && ctx.prompts, 'chat', { name })); parts.push( '# Werkzeuge\n' + 'Du hast Werkzeuge, um Bewerbungsdaten nachzuschlagen. Nutze sie, sobald der ' + diff --git a/lib/documents.js b/lib/documents.js index d69d357..da91340 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -9,6 +9,7 @@ const { jsPDF } = require('jspdf'); const fs = require('fs'); const path = require('path'); +const promptStore = require('./prompts'); // 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 @@ -127,7 +128,7 @@ const OUTPUT_SCHEMA = { 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; if (!apiKey) { throw new Error( @@ -162,19 +163,9 @@ async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen job.stellenbeschreibung || '(keine Beschreibung übermittelt)', ].filter((l) => l !== null).join('\n'); - const system = - '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.'; + // Role, tone and rules come from the (editable) prompt store; the JSON skeleton + // below stays in code because the response parsing depends on it. + const system = promptStore.get(prompts, 'unterlagen'); const skeleton = `{\n` + @@ -1140,8 +1131,8 @@ function hasLebenslauf(l) { || l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length); } -async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null }) { - const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen }); +async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '', signatur = null, bewerbungsfoto = null, prompts = null }) { + const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen, zusatzAnlagen, prompts }); const headline = data.headline || job.stelle || ''; const header = buildHeader(settings, data.kontakt, headline); @@ -1241,28 +1232,20 @@ const REPLY_SCHEMA = { // `settings` = { name, ... }. `hinweise` = optional free-text steering. // `typ` = 'antwort' (default) for a normal reply, or 'absage' for a polite, short // 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 isAbsage = typ === 'absage'; - const commonRules = - ' 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.'; + // Role prompt (per type) plus the shared style rules — both editable; the task + // description below stays in code because it defines the expected JSON. + const commonRules = promptStore.get(prompts, 'email_stil'); + // Joined with a space so neither part has to carry padding of its own — the + // user edits these as plain paragraphs. + const withRules = (rolle) => [rolle, commonRules].filter((s) => s && s.trim()).join(' '); let system, user, betreffDefault; if (isAbsage) { - system = - '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; + system = withRules(promptStore.get(prompts, 'email_absage', { name })); user = `# Kontext der Bewerbung\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.`; betreffDefault = 'Re: ' + (incoming.subject || ('Absage zur Bewerbung als ' + (job.stelle || ''))); } else { - system = - '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; + system = withRules(promptStore.get(prompts, 'email_antwort', { name })); user = `# Kontext der Bewerbung\n` + `Unternehmen: ${job.firma || '-'}\n` + diff --git a/lib/prompts.js b/lib/prompts.js new file mode 100644 index 0000000..254160e --- /dev/null +++ b/lib/prompts.js @@ -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 }; diff --git a/server.js b/server.js index 1be2f40..ba58c75 100644 --- a/server.js +++ b/server.js @@ -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. diff --git a/views/vorlagen.ejs b/views/vorlagen.ejs index f115204..7658baa 100644 --- a/views/vorlagen.ejs +++ b/views/vorlagen.ejs @@ -251,6 +251,63 @@ + + +
+ Hier steht, wie 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 Zurücksetzen holst du jederzeit den Standardtext zurück. +
+ +<%= p.beschreibung %>
+ + <% if (p.platzhalter && p.platzhalter.length) { %> +
+ Platzhalter:
+ <% p.platzhalter.forEach(ph => { %>
+ {{<%= ph %>}}
+ <% }) %>
+ – wird beim Generieren automatisch ersetzt (Name aus den Einstellungen).
+