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:
+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