diff --git a/lib/documents.js b/lib/documents.js index 8598e41..5b795ea 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -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, diff --git a/lib/prompts.js b/lib/prompts.js index 254160e..92472d6 100644 --- a/lib/prompts.js +++ b/lib/prompts.js @@ -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])); diff --git a/lib/suche.js b/lib/suche.js new file mode 100644 index 0000000..62bae70 --- /dev/null +++ b/lib/suche.js @@ -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 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, +}; diff --git a/server.js b/server.js index 3b48acd..f44b8f0 100644 --- a/server.js +++ b/server.js @@ -30,7 +30,7 @@ const multer = require('multer'); })(); const { - generateApplicationDocuments, generateEmailReply, renderDesignVorschau, + generateApplicationDocuments, generateEmailReply, generateFeinschliff, renderDesignVorschau, DOKUMENT_TYPEN, normalizeDokumente, } = require('./lib/documents'); const chat = require('./lib/chat'); @@ -48,6 +48,7 @@ const password = require('./lib/password'); const migrate = require('./lib/migrate-multiuser'); const { runMigration, importEnvIntoAdmin } = migrate; const suchprofil = require('./lib/suchprofil'); +const suche = require('./lib/suche'); const app = express(); const PORT = process.env.PORT || 3000; @@ -1555,6 +1556,11 @@ async function initializeDatabase() { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); + + // Volltext-Index über Bewerbungen + E-Mail-Korrespondenz. Muss nach den beiden + // Quelltabellen entstehen, weil die Trigger auf ihnen sitzen; der Backfill + // läuft nur beim ersten Start (siehe lib/suche.js). + await suche.ensureSchema({ exec, dbGet }); } // Initialize and start server initializeDatabase().then(async () => { @@ -1902,6 +1908,21 @@ initializeDatabase().then(async () => { } }); + // Volltextsuche für die Startseite: Bewerbungen samt ihrer E-Mail-Korrespondenz + // (Absender, Betreff, Text), Notizen und Stellenbeschreibung. Liefert je Treffer + // die Fundstellen mit Snippet — die Marker darin ersetzt der Client durch . + app.get('/api/suche', async (req, res) => { + try { + const q = String(req.query.q || ''); + if (q.trim().length < 2) return res.json({ q, treffer: [] }); + const treffer = await suche.suche(dbAll, uid(), q); + res.json({ q, treffer }); + } catch (error) { + console.error('Search error:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + // Poll generation status + current attachments for one application app.get('/api/bewerbungen/:id/generierung', async (req, res) => { try { @@ -2312,7 +2333,11 @@ initializeDatabase().then(async () => { job: { firma: bewerbung.firma, stelle: bewerbung.stelle }, settings, prompts: await loadPrompts(), + // Was der Nutzer über dem Antwortfeld vorgegeben hat, plus (beim zweiten + // Klick) der Entwurf, der gerade im Feld steht — dann überarbeitet die KI + // ihn, statt neu anzufangen. hinweise: String(req.body.hinweise || ''), + entwurf: String(req.body.entwurf || ''), typ: String(req.body.typ || 'antwort'), }); // Include the quoted original so the reply reads like a mail-client thread. @@ -3421,6 +3446,12 @@ initializeDatabase().then(async () => { "SELECT id FROM basis_dokumente WHERE user_id = ? AND typ = 'Lebenslauf' AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1", [ziel.id] ); + // Der KI-Feinschliff liest ALLE Basis-Unterlagen, nicht nur den Lebenslauf — + // ein Kurzprofil allein reicht ihm schon. + const unterlagen = await dbGet( + "SELECT id FROM basis_dokumente WHERE user_id = ? AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1", + [ziel.id] + ); const ollamaKey = await dbGet( "SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''", [ziel.id] @@ -3435,6 +3466,7 @@ initializeDatabase().then(async () => { wochentage: suchprofil.WOCHENTAGE, maxStaedte: suchprofil.MAX_STAEDTE, hatLebenslauf: Boolean(lebenslauf), + hatUnterlagen: Boolean(unterlagen), hatOllamaKey: Boolean(ollamaKey), fehler: req.query.fehler ? String(req.query.fehler) : null, hinweis: req.query.hinweis ? String(req.query.hinweis) : null, @@ -3465,6 +3497,47 @@ initializeDatabase().then(async () => { } }); + // KI-Vorschlag für den Feinschliff (Zusatzbegriffe / Ausschlüsse), abgeleitet + // aus den Basis-Unterlagen des Zielbenutzers. Der Vorschlag wird nur + // zurückgegeben, nicht gespeichert — der Nutzer prüft ihn im Formular und + // speichert selbst. Anders als der Suchlauf läuft das direkt hier: es ist ein + // einzelner Ollama-Aufruf, kein Web-Rechercheauftrag für den Host-Runner. + app.post('/jobsuche/feinschliff', async (req, res) => { + try { + const ziel = resolveZielUser(req); + if (!ziel) return res.status(403).json({ error: 'Zugriff verweigert – nur für Administratoren.' }); + + const unterlagen = await dbAll( + `SELECT typ, name, inhalt FROM basis_dokumente + WHERE user_id = ? AND inhalt IS NOT NULL AND inhalt != '' + ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 WHEN 'Profil/Kurzprofil' THEN 1 WHEN 'Anschreiben' THEN 2 ELSE 3 END, id ASC`, + [ziel.id] + ); + if (!unterlagen.length) { + return res.status(400).json({ + error: 'Keine Basis-Unterlagen hinterlegt – lege zuerst unter „Vorlagen“ einen Lebenslauf an.', + }); + } + + // Modus/Städte kommen aus dem Formular (ggf. ungespeichert), der Rest ist + // für den Vorschlag irrelevant. + const profil = suchprofil.fromForm({ ...req.body, aktiv: '0' }, sanitizeInput); + const text = unterlagen + .map((d) => `## ${d.typ}${d.name ? ` – ${d.name}` : ''}\n${String(d.inhalt).trim()}`) + .join('\n\n'); + + const vorschlag = await generateFeinschliff({ + unterlagen: text, + profil, + prompts: await loadPrompts(), + }); + res.json(vorschlag); + } catch (error) { + console.error('Error generating Feinschliff:', error); + res.status(500).json({ error: error.message || 'Serverfehler' }); + } + }); + // "Jetzt suchen": queue a run. Picked up by the host runner within a few minutes. app.post('/jobsuche/start', async (req, res) => { try { @@ -3712,11 +3785,11 @@ initializeDatabase().then(async () => { type: 'function', function: { name: 'suche_bewerbungen', - description: 'Durchsucht Bewerbungen nach Firmen- oder Stellenname (Teiltreffer). Nutze dies, wenn der Nutzer eine konkrete Firma/Stelle nennt oder fragt, was zu einer Firma bekannt ist. Liefert eine kompakte Trefferliste (id, firma, stelle, status, datum, ort) — hole Details mit bewerbung_detail.', + description: 'Volltextsuche über Bewerbungen UND ihre E-Mail-Korrespondenz: Firma, Stelle, Notizen, Stellenbeschreibung, Absender/Empfänger, Betreff und Mailtext. Nutze dies auch, wenn der Nutzer nur einen Namen einer Ansprechpartnerin, einen Betreff oder eine Formulierung aus einer E-Mail nennt. Liefert je Treffer die Bewerbung samt Fundstelle (wo der Begriff steht) — hole Details mit bewerbung_detail.', parameters: { type: 'object', properties: { - query: { type: 'string', description: 'Suchbegriff, z. B. Firmen- oder Stellenname (mind. 2 Zeichen)' }, + query: { type: 'string', description: 'Suchbegriff (mind. 2 Zeichen). Mehrere Wörter werden UND-verknüpft; "in Anführungszeichen" sucht die Wortfolge, feld:wert grenzt ein (firma:, stelle:, von:, betreff:), -wort schließt aus.' }, }, required: ['query'], }, @@ -3773,14 +3846,22 @@ initializeDatabase().then(async () => { if (name === 'suche_bewerbungen') { const q = String(a.query || '').trim(); if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' }; - const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`; - const rows = await dbAll( - `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen - WHERE user_id = ? AND (firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\') - ORDER BY datum DESC, created_at DESC LIMIT 20`, - [uid(), like, like] - ); - return { treffer: rows }; + // Dieselbe Volltextsuche wie auf der Startseite (lib/suche.js) — der Assistent + // findet eine Bewerbung dadurch auch über einen Namen aus der Korrespondenz. + const treffer = await suche.suche(dbAll, uid(), q, 20); + return { + treffer: treffer.map((t) => ({ + id: t.bewerbung_id, + ...(t.bewerbung || {}), + // Wo der Begriff steht, ohne Markierungszeichen — die versteht das Modell nicht. + fundstellen: t.fundstellen.map((f) => ({ + wo: f.quelle === 'email' + ? `E-Mail${f.email && f.email.betreff ? ` „${f.email.betreff}"` : ''}${f.email && f.email.von ? ` von ${f.email.von}` : ''}` + : f.feld_label, + auszug: suche.ohneMarker(f.snippet), + })), + })), + }; } if (name === 'list_bewerbungen') { const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 40); diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index fdbb10b..7feca6b 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -500,20 +500,34 @@ -
- -
+ +
+
+ -
+ <% if (basisAnhaenge && basisAnhaenge.length) { %> @@ -861,46 +875,100 @@ }); }); - // Generate an AI draft reply and drop it into the reply form. + // KI-Antwort: Der Nutzer gibt vor, was drinstehen soll; die KI schreibt + // die Mail dazu. Steht schon ein Entwurf im Feld, geht er mit — dann + // überarbeitet die KI ihn entlang der neuen Vorgabe, statt ihn (und die + // eigenen Änderungen daran) wegzuwerfen. document.querySelectorAll('.ai-reply-btn').forEach((btn) => { const form = document.getElementById(btn.dataset.target); - const typSelect = form ? form.querySelector('.ai-reply-typ') : null; + if (!form) return; + const typSelect = form.querySelector('.ai-reply-typ'); + const promptFeld = form.querySelector('.ai-reply-prompt'); + const status = form.querySelector('.ai-reply-status'); + const body = form.querySelector('.reply-body'); + const subject = form.querySelector('.reply-subject'); const label = btn.querySelector('.ai-reply-label'); - const syncLabel = () => { - if (!label || !typSelect) return; - label.textContent = typSelect.value === 'absage' ? 'KI-Absage generieren' : 'KI-Antwort generieren'; + + // Das zitierte Original steht unter dem Entwurf und ist nicht Teil davon. + let zitat = (body ? body.value : '').trim(); + + const entwurfText = () => { + let t = body ? body.value : ''; + if (zitat && t.trimEnd().endsWith(zitat)) t = t.trimEnd().slice(0, -zitat.length); + return t.trim(); }; - if (typSelect) { typSelect.addEventListener('change', syncLabel); syncLabel(); } - btn.addEventListener('click', async () => { - if (!form) return; + + const syncLabel = () => { + if (!label) return; + const absage = typSelect && typSelect.value === 'absage'; + const ueberarbeiten = entwurfText().length > 0; + label.textContent = ueberarbeiten + ? 'Entwurf überarbeiten' + : (absage ? 'KI-Absage generieren' : 'KI-Antwort generieren'); + }; + + const melde = (text, art) => { + if (!status) return; + status.textContent = text; + status.className = 'ai-reply-status text-xs ' + ( + art === 'fehler' ? 'text-red-600 dark:text-red-400' + : art === 'ok' ? 'text-green-700 dark:text-green-400' + : 'text-gray-500 dark:text-gray-400'); + }; + + if (typSelect) typSelect.addEventListener('change', syncLabel); + if (body) body.addEventListener('input', syncLabel); + syncLabel(); + + const generieren = async () => { const typ = typSelect ? typSelect.value : 'antwort'; + const hinweise = promptFeld ? promptFeld.value.trim() : ''; + const entwurf = entwurfText(); const prev = label ? label.textContent : ''; - if (label) label.textContent = 'Generiere …'; + if (label) label.textContent = entwurf ? 'Überarbeite …' : 'Generiere …'; btn.disabled = true; + melde(entwurf + ? 'Die KI überarbeitet den Entwurf nach deiner Vorgabe …' + : 'Die KI schreibt die Antwort …', 'info'); try { const res = await fetch('/bewerbung/' + btn.dataset.appId + '/email/ai-reply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email_id: btn.dataset.emailId, typ }), + body: JSON.stringify({ email_id: btn.dataset.emailId, typ, hinweise, entwurf }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Fehler'); - const body = form.querySelector('.reply-body'); - const subject = form.querySelector('.reply-subject'); if (body && data.text) { - // Generated reply on top, quoted original beneath — like a mail client. - body.value = data.text + (data.quote ? '\n\n' + data.quote : ''); + if (data.quote) zitat = data.quote; + // Entwurf oben, zitiertes Original darunter — wie im Mailprogramm. + body.value = data.text + (zitat ? '\n\n' + zitat : ''); body.focus(); try { body.setSelectionRange(data.text.length, data.text.length); } catch (e) {} } if (subject && data.betreff) subject.value = data.betreff; + melde(hinweise + ? 'Entwurf nach deiner Vorgabe erstellt – prüfen, anpassen, senden.' + : 'Entwurf erstellt. Für Änderungen oben vorgeben, was anders sein soll.', 'ok'); + syncLabel(); } catch (e) { - alert('KI-Antwort fehlgeschlagen: ' + e.message); - } finally { + melde('KI-Antwort fehlgeschlagen: ' + (e.message || 'Unbekannter Fehler'), 'fehler'); if (label) label.textContent = prev; + } finally { btn.disabled = false; + syncLabel(); } - }); + }; + + btn.addEventListener('click', generieren); + // Strg/Cmd + Enter im Prompt-Feld generiert direkt. + if (promptFeld) { + promptFeld.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { + ev.preventDefault(); + if (!btn.disabled) generieren(); + } + }); + } }); // While documents are being generated, poll for completion and diff --git a/views/index.ejs b/views/index.ejs index 890dde3..8117be2 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -13,8 +13,15 @@ - + + +
+ + + <% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %> -
+

Anstehende Termine

@@ -106,7 +130,7 @@ transform: rotate(90deg); } -
+
@@ -170,7 +194,7 @@ const hatVerlauf = app.verlauf && app.verlauf.length > 0; const hatDetails = hatNotizen || hatInterne || hatVerlauf; %> -
"> +
@@ -280,9 +304,6 @@ <% }); %>
- <% } else { %>

Keine Bewerbungen gefunden.

@@ -325,51 +346,236 @@ }); }); - // ---- Live-Firmensuche: filtert die Bewerbungsliste nach Firma/Stelle ---- + // ---- Volltextsuche (/api/suche): Bewerbungen + E-Mail-Korrespondenz ---- + // Die Liste unten bleibt der Normalzustand; sobald gesucht wird, tritt an + // ihre Stelle die gerankte Trefferliste mit Fundstellen. var input = document.getElementById('firmaSearch'); if (!input) return; var clear = document.getElementById('firmaSearchClear'); - var sections = qsa('.status-group'); - var noResults = document.getElementById('searchNoResults'); - var noResultsTerm = document.getElementById('searchNoResultsTerm'); + var hint = document.getElementById('firmaSearchHint'); + var spinner = document.getElementById('firmaSearchSpinner'); + var panel = document.getElementById('searchPanel'); + var results = document.getElementById('searchResults'); + var summary = document.getElementById('searchSummary'); + var empty = document.getElementById('searchEmpty'); + var emptyTerm = document.getElementById('searchEmptyTerm'); + var fehler = document.getElementById('searchError'); + var listCard = document.getElementById('listCard'); + var termineCard = document.getElementById('termineCard'); - function filter() { - var q = input.value.trim().toLowerCase(); - if (clear) clear.classList.toggle('hidden', !q); - var anyVisible = false; + var MIN = 2; // kürzere Eingaben liefern nur Rauschen + var MARK_START = '\u0002'; // Fundstellen-Marker aus lib/suche.js + var MARK_END = '\u0003'; + var timer = null; + var laufend = null; // AbortController der offenen Anfrage + var aktiv = -1; // per Tastatur ausgewählter Treffer - sections.forEach(function (sec) { - var rows = qsa('.app-row', sec); - var visible = 0; - rows.forEach(function (row) { - var match = !q || (row.dataset.search || '').indexOf(q) !== -1; - row.style.display = match ? '' : 'none'; - if (match) visible++; - }); - if (visible) anyVisible = true; + function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } - var countEl = sec.querySelector('[data-group-count]'); - if (countEl) countEl.textContent = visible; - sec.style.display = (visible === 0 && q) ? 'none' : ''; + // Snippet -> HTML: erst escapen (Mailtext ist Fremdinhalt), dann die + // Marker durch ersetzen. Andersherum wäre es eine XSS-Lücke. + function snippetHtml(s) { + return escapeHtml(s) + .split(escapeHtml(MARK_START)).join('') + .split(escapeHtml(MARK_END)).join(''); + } - // Während der Suche passende Gruppen aufklappen, nach dem Löschen alles wieder zu. - var body = sec.querySelector('.group-body'); - var chev = sec.querySelector('.group-toggle .chevron'); - setOpen(body, chev, !!q && visible > 0); + function datumDe(iso) { + if (!iso) return ''; + var d = new Date(iso); + return isNaN(d) ? '' : d.toLocaleDateString('de-DE'); + } + + // "Frau Müller " -> "Frau Müller" + function absender(v) { + var s = String(v || '').trim(); + var m = s.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/); + return (m ? m[1].trim() : s) || ''; + } + + // Woher der Treffer stammt — die Herkunft ist bei einer Mailsuche die + // eigentliche Information ("steht in der Mail von Frau Müller"). + function herkunft(f) { + if (f.quelle !== 'email' || !f.email) return f.feld_label || 'Bewerbung'; + var von = absender(f.email.richtung === 'out' ? f.email.an : f.email.von); + var d = datumDe(f.email.datum); + return 'E-Mail' + (von ? (f.email.richtung === 'out' ? ' an ' : ' von ') + von : '') + (d ? ', ' + d : ''); + } + + function trefferZeile(t, i) { + var b = t.bewerbung || {}; + var li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.dataset.index = String(i); + + var a = document.createElement('a'); + a.href = t.typ === 'email' && !t.bewerbung_id ? '/postfach' : '/bewerbung/' + t.bewerbung_id; + a.className = 'block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700/50'; + + var kopf = document.createElement('div'); + kopf.className = 'flex flex-wrap items-baseline gap-x-2 gap-y-1'; + var titel = document.createElement('span'); + titel.className = 'font-medium text-gray-900 dark:text-white'; + titel.textContent = t.bewerbung_id ? (b.firma || '(ohne Firma)') : 'E-Mail im Postfach'; + kopf.appendChild(titel); + if (b.stelle) { + var stelle = document.createElement('span'); + stelle.className = 'text-sm text-gray-600 dark:text-gray-300'; + stelle.textContent = '· ' + b.stelle; + kopf.appendChild(stelle); + } + if (b.status) { + var st = document.createElement('span'); + st.className = 'px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'; + st.textContent = b.status; + kopf.appendChild(st); + } + if (b.datum) { + var dt = document.createElement('span'); + dt.className = 'ml-auto text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap'; + dt.textContent = datumDe(b.datum); + kopf.appendChild(dt); + } + a.appendChild(kopf); + + (t.fundstellen || []).forEach(function (f) { + var zeile = document.createElement('p'); + zeile.className = 'mt-1.5 text-sm text-gray-600 dark:text-gray-300 flex flex-wrap items-baseline gap-x-2'; + var wo = document.createElement('span'); + wo.className = 'shrink-0 text-xs uppercase tracking-wide ' + + (f.quelle === 'email' ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500'); + wo.textContent = herkunft(f); + var text = document.createElement('span'); + text.className = 'min-w-0 break-words'; + text.innerHTML = snippetHtml(f.snippet); // vorher escaped, siehe snippetHtml + zeile.appendChild(wo); + zeile.appendChild(text); + a.appendChild(zeile); }); - if (noResults) { - noResults.style.display = (!anyVisible && q) ? '' : 'none'; - if (noResultsTerm) noResultsTerm.textContent = input.value.trim(); + li.appendChild(a); + return li; + } + + function markiere(i) { + var zeilen = qsa('li', results); + if (!zeilen.length) return; + aktiv = (i + zeilen.length) % zeilen.length; + zeilen.forEach(function (li, n) { + li.classList.toggle('bg-blue-50', n === aktiv); + li.classList.toggle('dark:bg-gray-700/60', n === aktiv); + li.setAttribute('aria-selected', n === aktiv ? 'true' : 'false'); + }); + zeilen[aktiv].scrollIntoView({ block: 'nearest' }); + } + + function zeigeListe() { + panel.classList.add('hidden'); + if (listCard) listCard.classList.remove('hidden'); + if (termineCard) termineCard.classList.remove('hidden'); + input.setAttribute('aria-expanded', 'false'); + aktiv = -1; + } + + function zeigeTreffer(daten) { + var treffer = daten.treffer || []; + results.innerHTML = ''; + treffer.forEach(function (t, i) { results.appendChild(trefferZeile(t, i)); }); + + summary.textContent = treffer.length + ? treffer.length + (treffer.length === 1 ? ' Treffer' : ' Treffer') + ' für „' + daten.q.trim() + '"' + : 'Keine Treffer'; + empty.classList.toggle('hidden', treffer.length > 0); + if (emptyTerm) emptyTerm.textContent = daten.q.trim(); + fehler.classList.add('hidden'); + + panel.classList.remove('hidden'); + if (listCard) listCard.classList.add('hidden'); + if (termineCard) termineCard.classList.add('hidden'); + input.setAttribute('aria-expanded', 'true'); + aktiv = -1; + } + + function zeigeFehler(msg) { + results.innerHTML = ''; + summary.textContent = ''; + empty.classList.add('hidden'); + fehler.textContent = msg; + fehler.classList.remove('hidden'); + panel.classList.remove('hidden'); + if (listCard) listCard.classList.add('hidden'); + if (termineCard) termineCard.classList.add('hidden'); + } + + async function suchen(q) { + if (laufend) laufend.abort(); // veraltete Antwort darf die neue nicht überholen + laufend = new AbortController(); + spinner.classList.remove('hidden'); + try { + var res = await fetch('/api/suche?q=' + encodeURIComponent(q), { + headers: { Accept: 'application/json' }, signal: laufend.signal, + }); + if (!res.ok) throw new Error('Die Suche ist fehlgeschlagen (' + res.status + ').'); + zeigeTreffer(await res.json()); + } catch (e) { + if (e.name === 'AbortError') return; // durch den nächsten Tastendruck ersetzt + zeigeFehler(e.message || 'Die Suche ist fehlgeschlagen.'); + } finally { + spinner.classList.add('hidden'); } } - input.addEventListener('input', filter); - if (clear) clear.addEventListener('click', function () { + function getippt() { + var q = input.value.trim(); + clear.classList.toggle('hidden', !q); + if (hint) hint.classList.toggle('hidden', !!q); + clearTimeout(timer); + if (laufend) { laufend.abort(); laufend = null; } + spinner.classList.add('hidden'); + if (q.length < MIN) { zeigeListe(); return; } + // Entprellt: erst tippen lassen, dann eine Anfrage stellen. + timer = setTimeout(function () { suchen(q); }, 180); + } + + input.addEventListener('input', getippt); + + input.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + input.value = ''; + getippt(); + input.blur(); + return; + } + if (panel.classList.contains('hidden')) return; + if (e.key === 'ArrowDown') { e.preventDefault(); markiere(aktiv + 1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); markiere(aktiv - 1); } + else if (e.key === 'Enter') { + var zeilen = qsa('li', results); + var ziel = zeilen[aktiv >= 0 ? aktiv : 0]; + var link = ziel && ziel.querySelector('a'); + if (link) { e.preventDefault(); link.click(); } + } + }); + + clear.addEventListener('click', function () { input.value = ''; - filter(); + getippt(); input.focus(); }); + + // "/" springt ins Suchfeld — solange man nicht ohnehin schon tippt. + document.addEventListener('keydown', function (e) { + if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return; + var t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return; + e.preventDefault(); + input.focus(); + input.select(); + }); })(); diff --git a/views/jobsuche.ejs b/views/jobsuche.ejs index eaf4f86..d1357d1 100644 --- a/views/jobsuche.ejs +++ b/views/jobsuche.ejs @@ -105,9 +105,27 @@
-

Feinschliff

-

- Optional. Rollen und Technologien kommen aus deinem Lebenslauf – hier ergänzt du nur Leitplanken. +

+
+

Feinschliff

+

+ Optional. Rollen und Technologien kommen aus deinem Lebenslauf – hier ergänzt du nur Leitplanken. +

+
+ +
+ +

+ Der KI-Vorschlag wertet deine Basis-Unterlagen aus und + überschreibt beide Felder. Gespeichert wird er erst, wenn du unten auf „Speichern“ klickst.

@@ -223,6 +241,58 @@ const initial = <%- JSON.stringify(laeufe.map(function (l) { return { status: l.status }; })) %>; if (offen(initial)) timer = setInterval(poll, 5000); })(); + + // "Per KI vorschlagen": lets the model derive the two guard-rail lists from the + // user's Basis-Unterlagen. Only fills the fields — saving stays a deliberate act. + (function () { + const btn = document.getElementById('feinschliffKi'); + if (!btn) return; + const label = document.getElementById('feinschliffKiText'); + const hinweis = document.getElementById('feinschliffHinweis'); + const zusatz = document.getElementById('zusatz_begriffe'); + const ausschluesse = document.getElementById('ausschluesse'); + const zielUser = <%- fremd ? zielUser.id : 'null' %>; + + const FARBE = { + info: 'text-gray-500 dark:text-gray-400', + ok: 'text-green-700 dark:text-green-400', + fehler: 'text-red-600 dark:text-red-400', + }; + function melde(text, art) { + hinweis.textContent = text; + hinweis.className = 'text-sm mt-3 ' + FARBE[art]; + } + + btn.addEventListener('click', async function () { + btn.disabled = true; + label.textContent = 'KI denkt nach …'; + melde('Die KI liest deine Basis-Unterlagen – das dauert einen Moment.', 'info'); + + const modus = document.querySelector('input[name="modus"]:checked'); + try { + const res = await fetch('/jobsuche/feinschliff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ziel_user: zielUser, + modus: modus ? modus.value : '', + staedte: document.getElementById('staedte').value, + }), + }); + const data = await res.json().catch(function () { return {}; }); + if (!res.ok) throw new Error(data.error || ('Fehler ' + res.status)); + + zusatz.value = data.zusatz_begriffe || ''; + ausschluesse.value = data.ausschluesse || ''; + melde('Vorschlag übernommen. Prüfe ihn und klicke auf „Speichern“.', 'ok'); + } catch (e) { + melde(e.message || 'Der KI-Vorschlag ist fehlgeschlagen.', 'fehler'); + } finally { + btn.disabled = false; + label.textContent = 'Per KI vorschlagen'; + } + }); + })();