// 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 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, };