diff --git a/lib/chat.js b/lib/chat.js index 0273327..f3df7af 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -1,9 +1,11 @@ -// Conversational chat over Ollama Cloud. +// Conversational chat over Ollama Cloud with tool calling. // // The rest of the app uses the LLM as a one-shot generator (cover letters, // e-mail replies). This module adds an interactive chat: it streams assistant -// tokens from Ollama (`stream: true`, NDJSON) and grounds the conversation in -// the user's own application data via a context-rich system prompt. +// tokens from Ollama (`stream: true`, NDJSON) and lets the assistant look up +// the user's application data on demand via tool calls instead of having every +// application baked into the system prompt (which would be slow and grow with +// the data). // // Unlike lib/documents.js, no JSON output schema is enforced — the model just // replies as text, token by token, so the UI can render progressively. @@ -11,34 +13,47 @@ const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000); +const MAX_TOOL_ROUNDS = 4; function isConfigured() { return Boolean(process.env.OLLAMA_API_KEY); } -// Stream a chat completion from Ollama. `messages` = [{role, content}] in -// chronological order (system prompt is prepended separately). `onToken` is -// called with each incremental text chunk as it arrives. Returns the full -// assistant text once the stream finishes. Aborts cleanly via `signal`. -async function streamChat({ system, messages, onToken, temperature = 0.6, signal }) { +// Stream a single chat completion from Ollama. `messages` = [{role, content}] +// in chronological order (system prompt is prepended separately). `tools` is an +// optional Ollama tool-definition array; when the model calls a tool the +// returned `toolCalls` is non-empty and `content` is usually empty. `onToken` +// fires with each incremental text chunk. Returns { content, toolCalls }. +// Aborts cleanly via `signal`. +async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) { const apiKey = process.env.OLLAMA_API_KEY; if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt.'); const allMessages = []; if (system) allMessages.push({ role: 'system', content: system }); - for (const m of messages) allMessages.push({ role: m.role, content: m.content }); + for (const m of messages) { + // Preserve tool_calls (assistant) and tool_name (tool result) so Ollama can + // match tool calls to their results across rounds. + const msg = { role: m.role, content: m.content }; + if (m.tool_calls) msg.tool_calls = m.tool_calls; + if (m.tool_name) msg.tool_name = m.tool_name; + allMessages.push(msg); + } + + const body = { + model: OLLAMA_MODEL, + stream: true, + options: { temperature }, + messages: allMessages, + }; + if (tools && tools.length) body.tools = tools; let res; try { res = await fetch(`${OLLAMA_HOST}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, - body: JSON.stringify({ - model: OLLAMA_MODEL, - stream: true, - options: { temperature }, - messages: allMessages, - }), + body: JSON.stringify(body), signal, }); } catch (err) { @@ -46,21 +61,23 @@ async function streamChat({ system, messages, onToken, temperature = 0.6, signal throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`); } if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`); + const text = await res.text().catch(() => ''); + throw new Error(`Ollama-API antwortete mit ${res.status}: ${text.slice(0, 300)}`); } if (!res.body || !res.body.getReader) { // Node without streaming body support: fall back to buffered response. const data = await res.json(); - const text = ((data && data.message && data.message.content) || '').trim(); + const msg = (data && data.message) || {}; + const text = (msg.content || '').trim(); if (onToken && text) onToken(text); - return text; + return { content: text, toolCalls: normalizeToolCalls(msg.tool_calls) }; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let full = ''; + const toolCalls = []; // Ollama streams one JSON object per line (NDJSON). Accumulate partial // lines across chunks, then parse each complete line. @@ -69,11 +86,18 @@ async function streamChat({ system, messages, onToken, temperature = 0.6, signal if (!line) return; let obj; try { obj = JSON.parse(line); } catch (e) { return; } // ignore keepalives - const delta = obj && obj.message && obj.message.content; + const msg = obj && obj.message; + if (!msg) return; + const delta = msg.content; if (delta) { full += delta; if (onToken) onToken(delta); } + if (msg.tool_calls && msg.tool_calls.length) { + // Ollama emits tool_calls (complete) on the final chunk; capture them. + toolCalls.length = 0; + toolCalls.push(...normalizeToolCalls(msg.tool_calls)); + } }; while (true) { @@ -94,14 +118,68 @@ async function streamChat({ system, messages, onToken, temperature = 0.6, signal } } handleLine(buffer); // flush trailing line - return full.trim(); + return { content: full.trim(), toolCalls }; } -// Build the German system prompt that grounds the assistant in the user's -// application data. `context` is gathered by the server from SQLite: -// { heute, settings, applications, upcomingTermine } -// Each application entry carries firma/stelle/status, notes, the (trimmed) -// job description, contact, and the recent e-mail correspondence subjects. +// Coerce Ollama's tool_calls into a stable shape: [{function:{name, arguments}}] +// where arguments is always a plain object (Ollama sometimes sends a string). +function normalizeToolCalls(raw) { + if (!Array.isArray(raw)) return []; + const out = []; + for (const tc of raw) { + const fn = (tc && tc.function) || {}; + let args = fn.arguments; + if (typeof args === 'string') { + try { args = JSON.parse(args); } catch (e) { args = {}; } + } + out.push({ function: { name: fn.name, arguments: args || {} } }); + } + return out; +} + +// Run a full agentic turn: stream the model's reply; if it calls tools, +// execute them (via `executeTool(name, args) -> any JSON-serialisable value`), +// feed the results back, and loop until the model answers in plain text (or +// MAX_TOOL_ROUNDS is hit). `onToken` streams the final text answer; `onToolCall` +// fires for each tool invocation so the UI can show what's being looked up. +async function runChat({ system, messages, tools, executeTool, onToken, onToolCall, signal, temperature = 0.6 }) { + const convo = messages.map((m) => ({ role: m.role, content: m.content })); + + for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + const { content, toolCalls } = await streamChat({ + system, messages: convo, tools, onToken, temperature, signal, + }); + if (!toolCalls || !toolCalls.length) return content; + + // Append the assistant's tool-call message verbatim so the model sees its + // own call in the next round, then one tool-result message per call. + convo.push({ + role: 'assistant', + content: content || '', + tool_calls: toolCalls, + }); + for (const tc of toolCalls) { + const name = tc.function.name; + if (onToolCall) onToolCall(name, tc.function.arguments); + let result; + try { + result = await executeTool(name, tc.function.arguments); + } catch (err) { + result = { error: err.message || String(err) }; + } + convo.push({ + role: 'tool', + tool_name: name, + content: JSON.stringify(result), + }); + } + } + throw new Error('Maximale Anzahl Tool-Aufrufe erreicht.'); +} + +// Build the German system prompt that grounds the assistant. `context` is the +// lightweight core gathered by the server: { heute, settings, profil }. +// Application data is NOT baked in — the assistant fetches it via tools. function buildContextPrompt(ctx) { const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber'; const heute = (ctx && ctx.heute) || ''; @@ -117,9 +195,18 @@ function buildContextPrompt(ctx) { 'Fasse Stellenbeschreibungen verständlich zusammen, statt sie wörtlich ' + 'wiederzugeben, und beziehe sie auf das Profil des Bewerbers.' ); + parts.push( + '# Werkzeuge\n' + + 'Du hast Werkzeuge, um Bewerbungsdaten nachzuschlagen. Nutze sie, sobald der ' + + 'Nutzer nach einer konkreten Bewerbung, Firma, Status, Stellenbeschreibung, ' + + 'Korrespondenz oder einem Termin fragt — rufe niemals Daten aus dem Gedächtnis ' + + 'ab, die du per Werkzeug holen kannst. Lege mehrere benötigte Werkzeuge in ' + + 'einer Runde parallel an. Wenn eine Suche keine Treffer liefert, weise den ' + + 'Nutzer darauf hin und frage nach Details (z. B. Link/Stellenbeschreibung).' + ); if (heute) { parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' + - 'Terminangaben unten sind UTC-Zeitstempel (ISO, endet auf Z). ' + + 'Terminangaben aus Werkzeugen sind UTC-Zeitstempel (ISO, endet auf Z). ' + 'Rechne in UTC-Zeiten und leite daraus ab, was "heute", "morgen" usw. ist. ' + 'Zeige dem Nutzer Terminzeiten in seiner lokalen Zeit (Europe/Berlin = UTC+1, ' + 'bzw. UTC+2 während der Sommerzeit).'); @@ -132,51 +219,7 @@ function buildContextPrompt(ctx) { '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 = [ - `• [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 termine = (ctx && ctx.upcomingTermine) || []; - if (termine.length) { - parts.push('# Kommende Termine'); - for (const t of termine) { - parts.push( - `• ${t.titel} — ${t.start}${t.bewerbung ? ` (Bewerbung ${t.bewerbung_id || '?'}, ${t.bewerbung})` : ''}` - ); - } - } - - parts.push( - '# Aufgabe\n' + - 'Beziehe dich bei Antworten auf den obigen Kontext, wenn relevant. ' + - 'Wenn der Nutzer nach einer konkreten Bewerbung fragt, die im Kontext ' + - 'nicht enthalten ist, weise darauf hin, dass du sie nicht findest.' - ); - return parts.join('\n\n'); } -module.exports = { isConfigured, streamChat, buildContextPrompt, OLLAMA_MODEL }; \ No newline at end of file +module.exports = { isConfigured, streamChat, runChat, buildContextPrompt, OLLAMA_MODEL, MAX_TOOL_ROUNDS }; \ No newline at end of file diff --git a/public/js/chat.js b/public/js/chat.js index b75f256..99457c5 100644 --- a/public/js/chat.js +++ b/public/js/chat.js @@ -243,7 +243,17 @@ } await consumeSSE(res, (ev) => { - if (ev.type === 'token') { + if (ev.type === 'tool') { + // Tool lookups run before the first text token; surface a muted hint + // inside the typing indicator so the user sees activity. + const typingEl = document.getElementById('typing'); + if (typingEl && !assistantBubble) { + const label = ev.label || ev.name || 'nachschlagen…'; + typingEl.innerHTML = '
' + + '🔍 ' + escapeHtml(label) + '
'; + } + scrollBottom(); + } else if (ev.type === 'token') { if (!assistantBubble) { if (typing) typing.remove(); assistantBubble = addMessage('assistant', ''); diff --git a/server.js b/server.js index 5bbe9ec..83584bf 100644 --- a/server.js +++ b/server.js @@ -2430,76 +2430,148 @@ 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, upcoming] = await Promise.all([ + const [settings, profilRows] = await Promise.all([ dbGet('SELECT name FROM settings WHERE id = 1'), - upcomingTermine(8), + 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` + ), ]); - // 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` - ); + // Lightweight core context only: name, date and the user's profile (static, + // small). All application/appointment data is fetched on demand via tools, + // so the system prompt stays tiny regardless of how many bewerbungen exist. 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: 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_id: t.bewerbung_id, bewerbung: t.bewerbung_firma, - })), - }; + return { heute, profil, settings: settings || {} }; + } + + // Ollama tool definitions the assistant can call to look up application data. + const CHAT_TOOLS = [ + { + 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.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Suchbegriff, z. B. Firmen- oder Stellenname (mind. 2 Zeichen)' }, + }, + required: ['query'], + }, + }, + }, + { + type: 'function', + function: { + name: 'list_bewerbungen', + description: 'Listet Bewerbungen auf, standardmäßig die jüngsten. Optional nach Status gefiltert. Für einen Überblick über alle laufenden/abgeschlossenen Bewerbungen.', + parameters: { + type: 'object', + properties: { + status: { type: 'string', description: 'Optional: nur Bewerbungen mit diesem Status (z. B. offen, absage, eingeladen)' }, + limit: { type: 'integer', description: 'Max. Anzahl Treffer (Standard 20, max 40)' }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'bewerbung_detail', + description: 'Liefert volle Details zu einer Bewerbung: Stellenbeschreibung, Notizen, interne Notizen, Kontakt, Quell-URL und die letzten Korrespondenz-Betreffe. Setze die id aus suche_bewerbungen/list_bewerbungen voraus.', + parameters: { + type: 'object', + properties: { id: { type: 'integer', description: 'Bewerbungs-ID' } }, + required: ['id'], + }, + }, + }, + { + type: 'function', + function: { + name: 'kommende_termine', + description: 'Liefert die nächsten Termine (Gespräche, Fristen) mit Titel, Startzeit (UTC-ISO), verknüpfter Bewerbung.', + parameters: { type: 'object', properties: {} }, + }, + }, + ]; + + // Tool labels shown in the UI while a tool call is in flight. + const CHAT_TOOL_LABELS = { + suche_bewerbungen: 'Bewerbungen werden durchsucht…', + list_bewerbungen: 'Bewerbungen werden geladen…', + bewerbung_detail: 'Bewerbungsdetails werden geladen…', + kommende_termine: 'Termine werden geladen…', + }; + + // Execute one tool call against the database. Returns a JSON-serialisable + // value that is fed back to the model as the tool result. + async function executeChatTool(name, args) { + const a = args || {}; + 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 firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\' + ORDER BY datum DESC, created_at DESC LIMIT 20`, + [like, like] + ); + return { treffer: rows }; + } + if (name === 'list_bewerbungen') { + const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 40); + const status = String(a.status || '').trim(); + const sql = `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen + ${status ? 'WHERE status = ?' : ''} ORDER BY datum DESC, created_at DESC LIMIT ?`; + const rows = await dbAll(sql, status ? [status, limit] : [limit]); + return { bewerbungen: rows }; + } + if (name === 'bewerbung_detail') { + const id = Number(a.id); + if (!id) return { error: 'keine id' }; + const row = await dbGet( + `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 = ?`, + [id] + ); + if (!row) return { error: 'nicht gefunden' }; + const em = await dbAll( + `SELECT direction, subject, from_addr FROM emails WHERE bewerbung_id = ? + ORDER BY email_date DESC, created_at DESC LIMIT 8`, + [id] + ); + return { + id: row.id, firma: row.firma, stelle: row.stelle, status: row.status, datum: row.datum, ort: row.ort, + quelle_url: row.quelle_url, + kontakt_email: row.ja_kontakt || null, + ansprechpartner: row.ja_ansprech || null, + notizen: (row.notizen || '').trim(), + interne_notizen: (row.interne_notizen || '').trim(), + stellenbeschreibung: (row.stellenbeschreibung || row.ja_beschreibung || '').trim().slice(0, 1200), + korrespondenz: em.map((e) => ({ + direction: e.direction, subject: e.subject, von: e.from_addr, + })), + }; + } + if (name === 'kommende_termine') { + const rows = await upcomingTermine(10); + return { + termine: rows.map((t) => ({ + titel: t.titel, start: t.start, + bewerbung_id: t.bewerbung_id, + bewerbung: t.bewerbung_firma || null, + })), + }; + } + return { error: 'unbekanntes Werkzeug: ' + name }; } // Chat page: list threads + render the active thread (or a fresh empty one). @@ -2631,11 +2703,14 @@ initializeDatabase().then(() => { let assistantText = ''; try { - assistantText = await chat.streamChat({ + assistantText = await chat.runChat({ system, messages, + tools: CHAT_TOOLS, signal: controller.signal, onToken: (delta) => send({ type: 'token', content: delta }), + onToolCall: (name) => send({ type: 'tool', name, label: CHAT_TOOL_LABELS[name] || name }), + executeTool: executeChatTool, }); } catch (err) { if (aborted) { res.end(); return; }