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:
+123
-4
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user