// Conversational chat over Ollama Cloud. // // 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. // // 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. 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); 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 }) { 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 }); 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, }), signal, }); } catch (err) { if (err.name === 'AbortError') throw new Error('Abgebrochen.'); 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)}`); } 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(); if (onToken && text) onToken(text); return text; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let full = ''; // Ollama streams one JSON object per line (NDJSON). Accumulate partial // lines across chunks, then parse each complete line. const handleLine = (line) => { line = line.trim(); if (!line) return; let obj; try { obj = JSON.parse(line); } catch (e) { return; } // ignore keepalives const delta = obj && obj.message && obj.message.content; if (delta) { full += delta; if (onToken) onToken(delta); } }; while (true) { let chunk; try { chunk = await reader.read(); } catch (err) { if (err.name === 'AbortError') throw new Error('Abgebrochen.'); throw err; } if (chunk.done) break; buffer += decoder.decode(chunk.value, { stream: true }); let nl; while ((nl = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1); handleLine(line); } } handleLine(buffer); // flush trailing line return full.trim(); } // 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. function buildContextPrompt(ctx) { const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber'; const heute = (ctx && ctx.heute) || ''; const parts = []; parts.push( 'Du bist ein hilfreicher, deutschsprachiger Bewerbungs-Assistent für ' + name + '. ' + 'Du beantwortest Fragen zu laufenden Bewerbungen, hilfst beim Formulieren von ' + 'Antworten, beim Vorbereiten auf Gespräche und beim Überblick über den Status. ' + '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 "-". ' + '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 + '. ' + 'Terminangaben unten 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).'); } 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 = [ `• [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 };