KI-Feinschliff, Volltextsuche über E-Mails, Prompt für die Antwort-Mail

Drei Erweiterungen, die sich server.js und lib/documents.js teilen und
deshalb zusammen liegen:

Jobsuche: Der Feinschliff (Zusatzbegriffe/Ausschluesse) laesst sich per KI
aus den eigenen Basis-Unterlagen vorschlagen. Der Rollen-Prompt ist wie die
uebrigen unter Vorlagen editierbar; gespeichert wird erst auf Klick.

Suche: Startseite und KI-Chat durchsuchen jetzt per SQLite-FTS5 auch die
E-Mail-Korrespondenz (Absender, Betreff, Text), Notizen und
Stellenbeschreibung - ein Name aus einer Mail findet die Bewerbung. Ranking
per bm25, Fundstellen mit Snippet, Feldfilter (firma:, von:), Phrasen,
Ausschluesse, Umlaut-Varianten (mueller/muller/mueller). Der Index wird von
SQL-Triggern gepflegt, weil Bewerbungen und Mails aus UI, REST-API, IMAP und
KI-Import geschrieben werden.

E-Mail: Vor dem Generieren laesst sich vorgeben, was in der Antwort stehen
soll; die Vorgabe geht als verbindliche Anweisung in den Prompt. Steht schon
ein Entwurf im Feld, ueberarbeitet die KI ihn, statt neu anzufangen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:56:41 +02:00
co-authored by Claude Opus 4.8
parent 3c7f5d7c74
commit d86e40a6ff
7 changed files with 984 additions and 77 deletions
+123 -4
View File
@@ -1990,13 +1990,48 @@ const REPLY_SCHEMA = {
// Draft a reply to a recruiter/company e-mail in the context of an application.
// `incoming` = { from, subject, text }; `job` = { firma, stelle };
// `settings` = { name, ... }. `hinweise` = optional free-text steering.
// `settings` = { name, ... }.
// `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', prompts = null }) {
//
// `hinweise` is what the user typed into the prompt field above the reply form
// ("Termin am Dienstag zusagen, nach dem Gehaltsrahmen fragen"). It is the point
// of the whole call: without it the model can only produce a generic reply, so it
// is passed as a binding instruction, not as background colour.
//
// `entwurf` is the draft currently in the reply box. When present, the model
// *revises* that text along the new instructions instead of starting over — that
// is how the field is actually used ("gleiche Antwort, aber kürzer und förmlicher"),
// and re-generating from scratch would throw the user's own edits away.
async function generateEmailReply({
incoming, job = {}, settings = {}, hinweise = '', entwurf = '', typ = 'antwort', prompts = null,
}) {
const name = (settings && settings.name) || 'der Bewerber';
const isAbsage = typ === 'absage';
const anweisung = String(hinweise || '').trim().slice(0, 2000);
const bisher = String(entwurf || '').trim().slice(0, 6000);
// Beide Blöcke stehen NACH der eingegangenen Mail und VOR der Aufgabe: das
// Zuletztgelesene wiegt beim Modell am schwersten, und genau das soll die
// Anweisung des Nutzers sein.
const anweisungsBlock = anweisung
? `# Anweisungen von ${name} für diese Antwort (verbindlich)\n` +
`${anweisung}\n\n` +
`Setze diese Anweisungen vollständig um - sie bestimmen den Inhalt der E-Mail und haben ` +
`Vorrang vor allgemeinen Höflichkeitsformeln. Was dort nicht steht und sich nicht aus der ` +
`eingegangenen Nachricht ergibt, gehört nicht in die Antwort: keine zusätzlichen Zusagen, ` +
`Termine oder Angaben dazuerfinden. Ist eine Anweisung unklar oder fehlt eine konkrete ` +
`Angabe, setze einen Platzhalter in eckigen Klammern, statt etwas anzunehmen.\n\n`
: '';
const entwurfsBlock = bisher
? `# Bisheriger Entwurf\n${bisher}\n\n` +
`Überarbeite DIESEN Entwurf gemäß den Anweisungen oben. Behalte bei, was passt - schreibe ` +
`nicht ohne Not alles neu, und verwirf keine inhaltlichen Punkte des Entwurfs, sofern die ` +
`Anweisungen das nicht verlangen.\n\n`
: '';
// 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');
@@ -2016,8 +2051,10 @@ async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise
`Von: ${incoming.from || '-'}\n` +
`Betreff: ${incoming.subject || '-'}\n\n` +
`${incoming.text || ''}\n\n` +
(hinweise && hinweise.trim() ? `# Hinweise für die Absage (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') +
anweisungsBlock +
entwurfsBlock +
`# Aufgabe\n` +
(bisher ? 'Gib den überarbeiteten Entwurf als vollständige E-Mail zurück. ' : '') +
`Formuliere eine kurze, höfliche Absage-E-Mail: Du hast eine andere Stelle angenommen ` +
`und ziehst dich aus diesem Prozess zurück. Struktur des Feldes "text": Anrede (an den ` +
`konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ein ` +
@@ -2038,8 +2075,10 @@ async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise
`Von: ${incoming.from || '-'}\n` +
`Betreff: ${incoming.subject || '-'}\n\n` +
`${incoming.text || ''}\n\n` +
(hinweise && hinweise.trim() ? `# Hinweise für die Antwort (aktiv berücksichtigen)\n${hinweise.trim()}\n\n` : '') +
anweisungsBlock +
entwurfsBlock +
`# Aufgabe\n` +
(bisher ? 'Gib den überarbeiteten Entwurf als vollständige E-Mail zurück. ' : '') +
`Formuliere eine passende Antwort-E-Mail. Struktur des Feldes "text": Anrede (an den ` +
`konkreten Absender, falls Name erkennbar, sonst "Sehr geehrte Damen und Herren,"), ` +
`2-4 kurze Absätze, Grußformel "Mit freundlichen Grüßen" und in der letzten Zeile der ` +
@@ -2057,6 +2096,85 @@ async function generateEmailReply({ incoming, job = {}, settings = {}, hinweise
};
}
// ===========================================================================
// AI job-search guard rails ("Feinschliff")
// ===========================================================================
const FEINSCHLIFF_SCHEMA = {
type: 'object',
properties: {
zusatz_begriffe: { type: 'array', items: { type: 'string' } },
ausschluesse: { type: 'array', items: { type: 'string' } },
},
required: ['zusatz_begriffe', 'ausschluesse'],
};
// How many terms per field we keep. The two lists end up verbatim in the search
// agent's prompt, so a long one dilutes the search instead of sharpening it.
const FEINSCHLIFF_MAX = 8;
// Suggest the two guard-rail lists of a Suchprofil ("Zusätzlich berücksichtigen"
// / "Ausschließen") from the applicant's own base documents. `unterlagen` is the
// concatenated text of their Vorlagen (Lebenslauf, Kurzprofil, ...), `profil` the
// current Suchprofil (modus + cities give the model the search's frame).
// Returns { zusatz_begriffe, ausschluesse } as comma-separated strings — exactly
// the shape the two textareas and the DB hold.
async function generateFeinschliff({ unterlagen, profil = {}, prompts = null }) {
const text = String(unterlagen || '').trim();
if (!text) {
throw new Error('Keine Basis-Unterlagen hinterlegt - ohne sie kann die KI nichts ableiten.');
}
const modusText = {
regional: 'regional (nur Stellen mit Arbeitsort in den genannten Städten)',
remote: '100 % Remote (deutschlandweit, ortsunabhängig)',
beides: 'regional in den genannten Städten und zusätzlich 100 % Remote deutschlandweit',
}[profil.modus] || 'regional';
const staedte = (profil.staedte || []).join(', ');
const system = promptStore.get(prompts, 'jobsuche_feinschliff');
const user =
`# Basis-Unterlagen des Bewerbers\n${text.slice(0, 6000)}\n\n` +
`# Rahmen der Suche\n` +
`Suchmodus: ${modusText}\n` +
(staedte ? `Städte: ${staedte}\n` : '') +
`\n# Aufgabe\n` +
`Schlage die beiden Leitplanken der Stellensuche vor:\n` +
`- "zusatz_begriffe": Begriffe, die die Suche zusätzlich berücksichtigen soll - Technologien, ` +
`Branchen, Arbeitsumfelder oder Arbeitszeitmodelle, die aus den Unterlagen belegbar sind ` +
`(z. B. "Rechenzentrum", "Systemhaus"). KEINE Rollen-/Stellenbezeichnungen.\n` +
`- "ausschluesse": Kriterien, bei denen ein Treffer verworfen werden soll (z. B. Anstellungsformen ` +
`oder Rollen, die erkennbar nicht zum Profil passen).\n` +
`Höchstens ${FEINSCHLIFF_MAX} Einträge je Liste, nach Wichtigkeit sortiert. Jeder Eintrag ist ein ` +
`kurzer Begriff (1-4 Wörter), kein Satz. Verwende ausschließlich den einfachen Bindestrich "-". ` +
`Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, ohne Markdown, ohne Code-Fences.`;
const parsed = await ollamaChatJSON({ system, user, schema: FEINSCHLIFF_SCHEMA, temperature: 0.4 });
// The model honours the schema keys only loosely; accept the usual synonyms and
// flatten each list into the comma-separated free text the profile stores. A
// model that answers with one comma-separated string instead of an array is
// split back apart rather than dropped.
const liste = (keys) => {
const roheListe = pickArray(parsed, keys);
const werte = roheListe.length ? roheListe : String(pick(parsed, keys) || '').split(/[,;\n]/);
const out = [];
for (const roh of werte) {
const v = str(roh).replace(/^[-•*\s]+/, '').replace(/[,;]+$/, '').trim();
if (!v) continue;
if (out.some((x) => x.toLowerCase() === v.toLowerCase())) continue;
out.push(v);
if (out.length >= FEINSCHLIFF_MAX) break;
}
return out.join(', ');
};
return {
zusatz_begriffe: liste(['zusatz_begriffe', 'zusatzbegriffe', 'zusatz', 'begriffe', 'einschluesse']),
ausschluesse: liste(['ausschluesse', 'ausschlüsse', 'ausschlusskriterien', 'exclude', 'ausschluss']),
};
}
// ===========================================================================
// Design preview
// ===========================================================================
@@ -2112,6 +2230,7 @@ function renderDesignVorschau({ settings = {}, signatur = null, bewerbungsfoto =
module.exports = {
generateApplicationDocuments,
generateEmailReply,
generateFeinschliff,
generateTailoredTexts,
renderLebenslaufPdf,
renderAnschreibenPdf,