From cf6cf9ee33c5f41179702474651f3f5471e809ef Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 7 Jul 2026 14:36:45 +0000 Subject: [PATCH] KI-Chat: reichhaltigerer Kontext (Stellenbeschreibung, Korrespondenz, Profil) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Assistent kannte nur Firma/Stelle/Status/Notizen und konnte daher keine firmenspezifischen Fragen beantworten. gatherChatContext lädt jetzt pro Bewerbung: Ort, interne Notizen, Stellenbeschreibung (bzw. verknüpftes Jobangebot), Kontakt/Ansprechpartner und die E-Mail-Korrespondenz (Betreffe). Zusätzlich wird der Lebenslauf des Bewerbers injiziert, damit Antworten auf "was für mich wichtig" zugeschnitten werden können. Relevante Bewerbungen = Termin-Bewerbungen + 12 jüngste (gebunden im Token-Budget). Co-Authored-By: Claude --- lib/chat.js | 45 ++++++++++++++++++++++----------- server.js | 73 +++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 89 insertions(+), 29 deletions(-) diff --git a/lib/chat.js b/lib/chat.js index 9312c8b..0273327 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -99,8 +99,9 @@ async function streamChat({ system, messages, onToken, temperature = 0.6, signal // Build the German system prompt that grounds the assistant in the user's // application data. `context` is gathered by the server from SQLite: -// { settings, applications, recentEmails, upcomingTermine } -// Each entry is already trimmed to the fields the prompt needs. +// { heute, settings, applications, upcomingTermine } +// Each application entry carries firma/stelle/status, notes, the (trimmed) +// job description, contact, and the recent e-mail correspondence subjects. function buildContextPrompt(ctx) { const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber'; const heute = (ctx && ctx.heute) || ''; @@ -112,7 +113,9 @@ function buildContextPrompt(ctx) { 'Antworte natürlich, knapp und auf Deutsch im lateinischen Alphabet. ' + 'Erfinde KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen, Firmen). ' + 'Wenn eine Angabe nötig ist, die du nicht weißt, setze einen klar erkennbaren ' + - 'Platzhalter in eckigen Klammern. Verwende nur den einfachen Bindestrich "-".' + 'Platzhalter in eckigen Klammern. Verwende nur den einfachen Bindestrich "-". ' + + 'Fasse Stellenbeschreibungen verständlich zusammen, statt sie wörtlich ' + + 'wiederzugeben, und beziehe sie auf das Profil des Bewerbers.' ); if (heute) { parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' + @@ -122,34 +125,46 @@ function buildContextPrompt(ctx) { 'bzw. UTC+2 während der Sommerzeit).'); } + if (ctx && ctx.profil) { + parts.push('# Dein Profil (Lebenslauf)\n' + ctx.profil + + '\nNutze dies, um Antworten auf den Bewerber zuzuschneiden: Wenn der Nutzer ' + + 'nach einer Firma fragt, hebe hervor, welche Anforderungen der Stelle zu ' + + 'seinem Profil passen und wo Lücken bestehen.'); + } + const apps = (ctx && ctx.applications) || []; if (apps.length) { parts.push('# Bewerbungen (jüngste zuerst)'); for (const a of apps) { const lines = [ - `• ${a.firma || '?'} – ${a.stelle || '?'} (Status: ${a.status || '—'}, Datum: ${a.datum || '—'})`, + `• [Bewerbung ${a.id}] ${a.firma || '?'} – ${a.stelle || '?'} ` + + `(Status: ${a.status || '—'}, Datum: ${a.datum || '—'}${a.ort ? `, Ort: ${a.ort}` : ''})`, ]; + if (a.kontakt || a.ansprechpartner) { + lines.push(` Kontakt: ${[a.ansprechpartner, a.kontakt].filter(Boolean).join(', ')}`); + } + if (a.quelle_url) lines.push(` Quelle: ${a.quelle_url}`); if (a.notizen) lines.push(` Notizen: ${a.notizen}`); + if (a.interne_notizen) lines.push(` Interne Notizen: ${a.interne_notizen}`); + if (a.stellenbeschreibung) lines.push(` Stellenbeschreibung: ${a.stellenbeschreibung}`); + const kor = a.korrespondenz || []; + if (kor.length) { + lines.push(' Korrespondenz (neueste zuerst):'); + for (const e of kor) { + const tag = e.direction === 'out' ? 'gesendet' : 'eingegangen'; + lines.push(` - ${tag}: ${e.subject || '(kein Betreff)'}${e.from ? ` — von ${e.from}` : ''}`); + } + } parts.push(lines.join('\n')); } } - const emails = (ctx && ctx.recentEmails) || []; - if (emails.length) { - parts.push('# Letzte eingegangene E-Mails'); - for (const e of emails) { - parts.push( - `• ${e.subject || '(kein Betreff)'} — von ${e.from || '?'} (${e.bewerbung || '?'})` - ); - } - } - const termine = (ctx && ctx.upcomingTermine) || []; if (termine.length) { parts.push('# Kommende Termine'); for (const t of termine) { parts.push( - `• ${t.titel} — ${t.start}${t.bewerbung ? ` (${t.bewerbung})` : ''}` + `• ${t.titel} — ${t.start}${t.bewerbung ? ` (Bewerbung ${t.bewerbung_id || '?'}, ${t.bewerbung})` : ''}` ); } } diff --git a/server.js b/server.js index 2cb4222..c4511e9 100644 --- a/server.js +++ b/server.js @@ -2324,29 +2324,74 @@ initializeDatabase().then(() => { // Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the // assistant answer is streamed back via Server-Sent Events. async function gatherChatContext() { - const [settings, applications, recentEmails, upcoming] = await Promise.all([ + const [settings, upcoming] = await Promise.all([ dbGet('SELECT name FROM settings WHERE id = 1'), - dbAll(`SELECT firma, stelle, status, datum, notizen FROM bewerbungen - ORDER BY datum DESC, created_at DESC LIMIT 25`), - dbAll(`SELECT e.subject, e.from_addr, b.firma AS bewerbung - FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id - WHERE e.direction = 'received' - ORDER BY e.email_date DESC, e.created_at DESC LIMIT 12`), upcomingTermine(8), ]); + // Relevant bewerbungen: those tied to upcoming appointments, plus the most + // recent ones. Detailed context (job description, correspondence) is loaded + // for these so the assistant can answer firmen-specific questions. + const terminIds = upcoming.map((t) => t.bewerbung_id).filter(Boolean); + const recent = await dbAll( + 'SELECT id FROM bewerbungen ORDER BY datum DESC, created_at DESC LIMIT 12' + ); + const idSet = new Set([...terminIds, ...recent.map((r) => r.id)]); + const ids = [...idSet]; + const apps = ids.length + ? await dbAll( + `SELECT b.id, b.firma, b.stelle, b.status, b.datum, b.ort, b.notizen, + b.interne_notizen, b.stellenbeschreibung, b.quelle_url, + j.kontakt_email AS ja_kontakt, j.ansprechpartner AS ja_ansprech, j.beschreibung AS ja_beschreibung + FROM bewerbungen b + LEFT JOIN jobangebote j ON j.verknuepfte_bewerbung_id = b.id + WHERE b.id IN (${ids.map(() => '?').join(',')}) + ORDER BY b.datum DESC, b.created_at DESC`, + ids + ) + : []; + // Per-application correspondence (subjects only), newest first per app. + const emailsByApp = new Map(); + if (ids.length) { + const emRows = await dbAll( + `SELECT bewerbung_id, direction, subject, from_addr + FROM emails + WHERE bewerbung_id IN (${ids.map(() => '?').join(',')}) + ORDER BY bewerbung_id, email_date DESC, created_at DESC`, + ids + ); + for (const e of emRows) { + if (!emailsByApp.has(e.bewerbung_id)) emailsByApp.set(e.bewerbung_id, []); + const arr = emailsByApp.get(e.bewerbung_id); + if (arr.length < 5) arr.push({ direction: e.direction, subject: e.subject, from: e.from_addr }); + } + } + // The user's own profile (Lebenslauf / Kurzprofil) so the assistant can + // relate job descriptions back to the applicant's background. + const profilRows = await dbAll( + `SELECT inhalt FROM basis_dokumente + WHERE typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != '' + ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END` + ); + const profil = (profilRows.map((r) => (r.inhalt || '').trim()).join('\n\n---\n\n')).slice(0, 1800); + const heute = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); return { heute, + profil, settings: settings || {}, - applications: applications.map((a) => ({ - firma: a.firma, stelle: a.stelle, status: a.status, - datum: a.datum, notizen: (a.notizen || '').slice(0, 240), - })), - recentEmails: recentEmails.map((e) => ({ - subject: e.subject, from: e.from_addr, bewerbung: e.bewerbung, + applications: apps.map((a) => ({ + id: a.id, + firma: a.firma, stelle: a.stelle, status: a.status, datum: a.datum, ort: a.ort, + quelle_url: a.quelle_url, + notizen: (a.notizen || '').trim().slice(0, 240), + interne_notizen: (a.interne_notizen || '').trim().slice(0, 240), + stellenbeschreibung: (a.stellenbeschreibung || a.ja_beschreibung || '').trim().slice(0, 900), + kontakt: a.ja_kontakt || null, + ansprechpartner: a.ja_ansprech || null, + korrespondenz: emailsByApp.get(a.id) || [], })), upcomingTermine: upcoming.map((t) => ({ - titel: t.titel, start: t.start, bewerbung: t.bewerbung_firma, + titel: t.titel, start: t.start, bewerbung_id: t.bewerbung_id, bewerbung: t.bewerbung_firma, })), }; }