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,
+19
View File
@@ -88,6 +88,25 @@ const DEFAULTS = [
'Buzzword-Paare und ohne Selbstetiketten wie "Als Muttersprachler ...". Verwende ausschließlich ' +
'deutsche Sprache und das lateinische Alphabet - keine fremdsprachigen Wörter oder Schriftzeichen.',
},
{
key: 'jobsuche_feinschliff',
titel: 'Jobsuche: Feinschliff vorschlagen',
beschreibung:
'Rolle für den KI-Vorschlag der Leitplanken deiner Jobsuche (zusätzlich berücksichtigen / ' +
'ausschließen). Grundlage sind deine Basis-Unterlagen oben.',
platzhalter: [],
inhalt:
'Du bist ein erfahrener Recruiter und stellst die Leitplanken für die automatische ' +
'Stellensuche eines Bewerbers auf. Du liest seine Basis-Unterlagen (Lebenslauf, Kurzprofil, ' +
'Anschreiben) und leitest daraus ab, welche Zusatzbegriffe eine Stellensuche treffsicherer ' +
'machen und welche Treffer von vornherein verworfen werden sollten. Du erfindest nichts: ' +
'Jeder Zusatzbegriff muss sich aus den Unterlagen belegen lassen (nachgewiesene Technologien, ' +
'Branchen, Umfelder, Arbeitszeitmodelle). Ausschlüsse leitest du aus dem ab, was erkennbar ' +
'nicht zum Bewerber passt - etwa Stellen weit unter oder weit über seinem Erfahrungsniveau, ' +
'fachfremde Rollen oder Anstellungsformen, die er offensichtlich nicht sucht. Keine ' +
'Rollenbezeichnungen als Zusatzbegriffe: die leitet die Suche ohnehin selbst aus dem ' +
'Lebenslauf ab. Antworte auf Deutsch im lateinischen Alphabet.',
},
];
const BY_KEY = new Map(DEFAULTS.map((p) => [p.key, p]));
+344
View File
@@ -0,0 +1,344 @@
// Volltextsuche über Bewerbungen und ihre E-Mail-Korrespondenz (SQLite FTS5).
//
// Bisher filterte die Startseite nur clientseitig über Firma + Stelle der schon
// gerenderten Zeilen. Damit war unauffindbar, was nur in einer E-Mail steht — der
// Name der Ansprechpartnerin, ein Betreff, eine Zusage im Fließtext. Diese
// Suche indiziert daher beides.
//
// Aufbau: ein FTS5-Index aus *Fragmenten*. Ein Fragment ist entweder eine
// Bewerbung (Firma, Stelle, Notizen, Stellenbeschreibung …) oder eine einzelne
// E-Mail (Absender/Empfänger, Betreff, Text). Jedes E-Mail-Fragment trägt Firma
// und Stelle seiner Bewerbung mit — nur so findet eine Anfrage wie
// "müller bosch" die Bewerbung, obwohl der Name allein in der E-Mail und die
// Firma allein in der Bewerbung steht (FTS5 verknüpft Terme innerhalb *einer*
// Zeile mit UND).
//
// Gepflegt wird der Index von SQL-Triggern, nicht von der Anwendung: Bewerbungen
// und E-Mails werden an vielen Stellen geschrieben (Web-UI, REST-API, IMAP-Abruf,
// KI-Import), und ein vergessener Aufruf würde den Index still veralten lassen.
//
// Die Ergebnisse werden nach Bewerbung gruppiert zurückgegeben: eine Bewerbung
// ist ein Treffer, die Fragmente sagen, *wo* sie getroffen wurde ("in E-Mail von
// Frau Müller"). E-Mails ohne zugeordnete Bewerbung (Postfach) sind eigene Treffer.
// Spaltengewichte für bm25: ein Firmentreffer wiegt schwerer als ein Wort
// irgendwo im Mailtext. Reihenfolge = Spaltenreihenfolge im Index.
const GEWICHTE = [12.0, 8.0, 6.0, 4.0, 1.0]; // firma, stelle, person, betreff, text
// Beschnitt pro Mailtext: eine lange Signatur/Zitatkette bläht den Index auf,
// ohne die Suche besser zu machen.
const MAX_TEXT = 20000;
// Wie viele Bewerbungen eine Suche höchstens zurückgibt.
const LIMIT = 25;
// Markierungen um Fundstellen im Snippet. Bewusst keine HTML-Tags: die Rohtexte
// werden erst im Client escaped und die Marker danach durch <mark> ersetzt, sonst
// wäre der Snippet ein XSS-Vektor (Mailtexte sind Fremdinhalt).
const MARK_START = '\u0002';
const MARK_END = '\u0003';
// Feld-Filter, die der Nutzer tippen kann: "firma:bosch", "von:müller".
const FELD_ALIASE = {
firma: 'firma', unternehmen: 'firma',
stelle: 'stelle', job: 'stelle', position: 'stelle',
person: 'person', von: 'person', an: 'person', name: 'person', kontakt: 'person',
betreff: 'betreff', subject: 'betreff',
text: 'text', notiz: 'text', notizen: 'text', mail: 'text', inhalt: 'text',
};
// Der Bewerbungs-Teil eines Fragments (Notizen, Beschreibung, Metadaten) — als
// SQL-Ausdruck, weil ihn Trigger und Backfill gleichermaßen brauchen.
const BEWERBUNG_TEXT = `
trim(coalesce(%s.notizen, '') || ' ' || coalesce(%s.interne_notizen, '') || ' ' ||
coalesce(%s.llm_notizen, '') || ' ' || coalesce(%s.ort, '') || ' ' ||
coalesce(%s.art, '') || ' ' || coalesce(%s.status, '') || ' ' ||
coalesce(%s.labels, '') || ' ' || coalesce(%s.stellenbeschreibung, '') || ' ' ||
coalesce(%s.quelle_url, ''))
`;
const bewerbungText = (alias) => BEWERBUNG_TEXT.replace(/%s/g, alias);
// Schema + Trigger + einmaliger Backfill. Idempotent: läuft bei jedem Start.
async function ensureSchema({ exec, dbGet }) {
await exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS suche_index USING fts5(
firma, stelle, person, betreff, text,
user_id UNINDEXED,
bewerbung_id UNINDEXED,
typ UNINDEXED,
ref_id UNINDEXED,
meta UNINDEXED,
tokenize = 'unicode61 remove_diacritics 2'
)
`);
// Trigger. "typ" trennt die beiden Fragmentarten, "ref_id" zeigt auf die
// Quellzeile — beides brauchen die Trigger, um gezielt zu löschen.
await exec(`
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ai AFTER INSERT ON bewerbungen BEGIN
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
END
`);
// Beim Ändern: eigenes Fragment neu aufbauen und Firma/Stelle in den
// E-Mail-Fragmenten nachziehen (sie tragen sie denormalisiert mit).
await exec(`
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_au AFTER UPDATE ON bewerbungen BEGIN
DELETE FROM suche_index WHERE typ = 'bewerbung' AND ref_id = old.id;
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
UPDATE suche_index SET firma = coalesce(new.firma, ''), stelle = coalesce(new.stelle, '')
WHERE typ = 'email' AND bewerbung_id = new.id;
END
`);
await exec(`
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ad AFTER DELETE ON bewerbungen BEGIN
DELETE FROM suche_index WHERE bewerbung_id = old.id;
END
`);
// E-Mails: Firma/Stelle der zugeordneten Bewerbung wandern ins Fragment, damit
// "müller bosch" trifft. meta trägt, was das Ergebnis anzeigt (Betreff, Absender,
// Datum, Richtung) — als JSON, unindiziert.
const emailFragment = () => `
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
trim(coalesce(new.from_addr, '') || ' ' || coalesce(new.to_addr, '')),
coalesce(new.subject, ''), substr(coalesce(new.body_text, ''), 1, ${MAX_TEXT}),
new.user_id, new.bewerbung_id, 'email', new.id,
json_object('betreff', coalesce(new.subject, ''), 'von', coalesce(new.from_addr, ''),
'an', coalesce(new.to_addr, ''), 'datum', coalesce(new.email_date, new.created_at),
'richtung', coalesce(new.direction, ''))
FROM (SELECT 1) LEFT JOIN bewerbungen b ON b.id = new.bewerbung_id;
`;
await exec(`
CREATE TRIGGER IF NOT EXISTS emails_suche_ai AFTER INSERT ON emails BEGIN
${emailFragment()}
END
`);
// Eine E-Mail wird u. a. beim Zuordnen im Postfach aktualisiert (bewerbung_id) —
// dann muss das Fragment mitsamt Firma/Stelle neu entstehen.
await exec(`
CREATE TRIGGER IF NOT EXISTS emails_suche_au AFTER UPDATE ON emails BEGIN
DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
${emailFragment()}
END
`);
await exec(`
CREATE TRIGGER IF NOT EXISTS emails_suche_ad AFTER DELETE ON emails BEGIN
DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
END
`);
// Backfill: nur beim allerersten Start nach der Einführung (Index noch leer,
// Daten aber vorhanden). Danach halten die Trigger alles aktuell.
const leer = await dbGet('SELECT count(*) AS n FROM suche_index');
if (leer && leer.n === 0) {
await exec(`
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''), coalesce(b.email_empfaenger, ''), '',
${bewerbungText('b')}, b.user_id, b.id, 'bewerbung', b.id, ''
FROM bewerbungen b
`);
await exec(`
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
trim(coalesce(e.from_addr, '') || ' ' || coalesce(e.to_addr, '')),
coalesce(e.subject, ''), substr(coalesce(e.body_text, ''), 1, ${MAX_TEXT}),
e.user_id, e.bewerbung_id, 'email', e.id,
json_object('betreff', coalesce(e.subject, ''), 'von', coalesce(e.from_addr, ''),
'an', coalesce(e.to_addr, ''), 'datum', coalesce(e.email_date, e.created_at),
'richtung', coalesce(e.direction, ''))
FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id
`);
}
}
// ---------------------------------------------------------------------------
// Eingabe -> FTS5-MATCH-Ausdruck
// ---------------------------------------------------------------------------
// Was der Nutzer tippen darf:
// müller -> Präfixsuche über alle Felder
// "frau müller" -> Wortfolge
// firma:bosch -> nur in diesem Feld
// -zeitarbeit -> schließt Treffer aus
// Alles andere (FTS-Sonderzeichen wie * : ^ NEAR) wird entschärft: jeder Term
// geht in Anführungszeichen in die Query, damit eine Eingabe wie "c++" oder ein
// Doppelpunkt keinen Syntaxfehler auslöst statt zu suchen.
function buildMatch(roh) {
const q = String(roh == null ? '' : roh).trim();
if (!q) return null;
const TOKEN = /(-?)(?:(\w+):)?(?:"([^"]*)"|(\S+))/gu;
const positiv = [];
const negativ = [];
let m;
while ((m = TOKEN.exec(q)) !== null) {
const [, minus, feldRoh, phrase, wort] = m;
const inhalt = (phrase != null ? phrase : wort || '').trim();
// Ohne Buchstaben/Ziffern bleibt nichts Suchbares übrig (z. B. "--" oder ":").
const sauber = inhalt.replace(/"/g, ' ').trim();
if (!/[\p{L}\p{N}]/u.test(sauber)) continue;
const feld = feldRoh ? FELD_ALIASE[feldRoh.toLowerCase()] : null;
// Unbekanntes Präfix ("foo:bar") ist kein Feldfilter, sondern Suchtext.
const suchtext = feldRoh && !feld ? `${feldRoh} ${sauber}` : sauber;
const term = umlautTerm(suchtext);
// Ein Feldfilter darf auf einen geklammerten Ausdruck zeigen: {firma} : ("a"* OR "b"*)
const mitFeld = feld ? `{${feld}} : ${term}` : term;
(minus ? negativ : positiv).push(mitFeld);
}
// Eine reine Ausschlussfrage ("-zeitarbeit") hat keinen Anker — FTS5 braucht
// mindestens einen positiven Term, und "alles außer X" ist ohnehin keine Suche.
if (!positiv.length) return null;
const basis = positiv.join(' AND ');
return negativ.length ? `${basis} NOT (${negativ.join(' OR ')})` : basis;
}
// Ein Term als FTS5-Ausdruck, mit Präfixsuche ("bosch"* findet auch
// "boschgruppe" — beim Tippen der Normalfall).
//
// Dazu die deutsche Umlaut-Frage: der Index faltet Diakritika (Müller -> muller),
// deshalb findet "muller" bereits "Müller". Die ausgeschriebene Form "mueller"
// aber nicht — und genau die tippt man bei Namen ständig. Also wird zusätzlich
// die Variante mit aufgelöstem ue/oe/ae/ss gesucht, ODER-verknüpft. Falsche
// Auflösungen ("Neuenkirchen" -> "nunkirchen") laufen dabei einfach ins Leere,
// der Originalterm bleibt ja daneben stehen.
function umlautTerm(text) {
const roh = `"${text}"*`;
const variante = text
.replace(/ue/gi, 'u').replace(/oe/gi, 'o').replace(/ae/gi, 'a')
.replace(/ß/g, 'ss');
if (variante.toLowerCase() === text.toLowerCase()) return roh;
return `(${roh} OR "${variante}"*)`;
}
// ---------------------------------------------------------------------------
// Suche
// ---------------------------------------------------------------------------
const SPALTEN = ['firma', 'stelle', 'person', 'betreff', 'text'];
const FELD_LABEL = { firma: 'Firma', stelle: 'Stelle', person: 'Kontakt', betreff: 'Betreff', text: 'Text' };
// Findet Bewerbungen (und nicht zugeordnete E-Mails) zu einer Suchanfrage.
// `dbAll` ist der Query-Helfer der App, `userId` grenzt hart auf den Besitzer ein
// (der Index ist gemeinsam, die unindizierte user_id-Spalte trennt die Mandanten).
// Rückgabe: nach Score sortierte Treffer, jeder mit Fundstellen + Snippets.
async function suche(dbAll, userId, roh, limit = LIMIT) {
const match = buildMatch(roh);
if (!match || !userId) return [];
const gewichte = GEWICHTE.join(', ');
const snippets = SPALTEN
.map((_, i) => `snippet(suche_index, ${i}, '${MARK_START}', '${MARK_END}', '…', 12) AS s_${i}`)
.join(', ');
// Fragmente laden (mehrere je Bewerbung möglich), das Gruppieren macht JS —
// in SQL ginge es nur mit einem zweiten Durchlauf über die FTS-Tabelle.
let rows;
try {
rows = await dbAll(
`SELECT bewerbung_id, typ, ref_id, meta, ${snippets},
bm25(suche_index, ${gewichte}) AS score
FROM suche_index
WHERE suche_index MATCH ? AND user_id = ?
ORDER BY score
LIMIT ?`,
[match, userId, limit * 4] // Puffer: mehrere Fragmente fallen auf eine Bewerbung zusammen
);
} catch (e) {
// Eine Eingabe, die FTS5 trotz Entschärfung nicht parst, ist kein Serverfehler,
// sondern schlicht kein Treffer.
if (/fts5|syntax/i.test(e.message)) return [];
throw e;
}
if (!rows.length) return [];
// Fragmente -> Treffer je Bewerbung. Nicht zugeordnete E-Mails bleiben einzeln.
const treffer = new Map();
for (const r of rows) {
const key = r.bewerbung_id ? `b${r.bewerbung_id}` : `e${r.ref_id}`;
if (!treffer.has(key)) {
treffer.set(key, {
typ: r.bewerbung_id ? 'bewerbung' : 'email',
bewerbung_id: r.bewerbung_id || null,
email_id: r.bewerbung_id ? null : r.ref_id,
score: r.score,
fundstellen: [],
});
}
const t = treffer.get(key);
t.score = Math.min(t.score, r.score); // bm25: kleiner = besser
const fund = fundstelle(r);
if (fund) t.fundstellen.push(fund);
}
const liste = [...treffer.values()].sort((a, b) => a.score - b.score).slice(0, limit);
await ergaenzeBewerbungen(dbAll, userId, liste);
for (const t of liste) t.fundstellen = t.fundstellen.slice(0, 3);
return liste;
}
// Aus einem Fragment die beste Fundstelle bauen: die Spalte mit Markierung im
// Snippet (FTS5 liefert für Spalten ohne Treffer den Anfang des Textes zurück).
function fundstelle(row) {
for (let i = 0; i < SPALTEN.length; i++) {
const s = row[`s_${i}`];
if (!s || s.indexOf(MARK_START) === -1) continue;
const meta = row.meta ? sicherJson(row.meta) : null;
return {
quelle: row.typ, // 'bewerbung' | 'email'
feld: SPALTEN[i],
feld_label: FELD_LABEL[SPALTEN[i]],
snippet: s,
email: meta && row.typ === 'email' ? meta : null,
};
}
return null;
}
function sicherJson(s) {
try { return JSON.parse(s); } catch (e) { return null; }
}
// Snippet ohne die Fundstellen-Marker — für Verbraucher, die nichts hervorheben
// (der KI-Chat etwa: Steuerzeichen im Tool-Ergebnis würden das Modell nur irritieren).
function ohneMarker(s) {
return String(s == null ? '' : s).split(MARK_START).join('').split(MARK_END).join('');
}
// Die Anzeigedaten der getroffenen Bewerbungen nachladen (der Index hält nur
// Suchtext). Eine Abfrage für alle Treffer.
async function ergaenzeBewerbungen(dbAll, userId, liste) {
const ids = liste.filter((t) => t.bewerbung_id).map((t) => t.bewerbung_id);
if (!ids.length) return;
const rows = await dbAll(
`SELECT id, firma, stelle, status, datum, art FROM bewerbungen
WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`,
[userId, ...ids]
);
const byId = new Map(rows.map((r) => [r.id, r]));
for (const t of liste) {
const b = t.bewerbung_id ? byId.get(t.bewerbung_id) : null;
if (b) t.bewerbung = b;
}
// Eine Bewerbung, die es nicht mehr gibt (Index hinkt hinterher), fällt raus.
for (let i = liste.length - 1; i >= 0; i--) {
if (liste[i].typ === 'bewerbung' && !liste[i].bewerbung) liste.splice(i, 1);
}
}
module.exports = {
ensureSchema, suche, buildMatch, ohneMarker,
MARK_START, MARK_END, LIMIT, FELD_ALIASE,
};