gatherChatContext() brach mit SQLITE_ERROR ab, weil "e.from_addr AS from"
das SQL-Schlüsselwort from als Alias nutzte. Der Fehler wurde im Route-Handler
geschluckt (context={}), sodass der Assistent keinen Zugriff auf Bewerbungen/
Termine/E-Mails hatte. Alias entfernt.
Zusätzlich erhält der System-Prompt jetzt das heutige Datum, damit der
Assistent "morgen"/"heute" aus den UTC-Termin-Zeitstempeln ableiten kann.
Co-Authored-By: Claude <noreply@anthropic.com>
167 lines
6.2 KiB
JavaScript
167 lines
6.2 KiB
JavaScript
// 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:
|
||
// { settings, applications, recentEmails, upcomingTermine }
|
||
// Each entry is already trimmed to the fields the prompt needs.
|
||
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 "-".'
|
||
);
|
||
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).');
|
||
}
|
||
|
||
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 || '—'})`,
|
||
];
|
||
if (a.notizen) lines.push(` Notizen: ${a.notizen}`);
|
||
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})` : ''}`
|
||
);
|
||
}
|
||
}
|
||
|
||
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 }; |