Files
jobbi-bewerbung/lib/chat.js
T
thomasandClaude Opus 4.8 65a5943993 KI-Prompts unter Vorlagen editierbar statt hardcoded
Rolle, Tonfall und Regeln der KI (Unterlagen, Chat, E-Mail-Antwort,
E-Mail-Absage, gemeinsame Stilregeln) lagen fest im Code. Sie liegen jetzt
als Defaults in lib/prompts.js und lassen sich auf der Vorlagen-Seite je
Prompt anpassen und wieder zuruecksetzen.

Nur die System-Prompts sind editierbar. Die User-Prompts tragen das
JSON-Skeleton, gegen das die Antwort geparst wird - ein Tippfehler dort
wuerde die Generierung lahmlegen, also bleiben sie im Code.

Gespeichert wird nur, was abweicht: ein Override ist eine Zeile in der neuen
Tabelle `prompts`, "Zuruecksetzen" loescht sie. Damit bleiben die Defaults im
Code die Wahrheit und wandern bei Updates automatisch mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:09:03 +02:00

219 lines
8.5 KiB
JavaScript

// 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 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.
const promptStore = require('./prompts');
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 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) {
// 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(body),
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 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 msg = (data && data.message) || {};
const text = (msg.content || '').trim();
if (onToken && text) onToken(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.
const handleLine = (line) => {
line = line.trim();
if (!line) return;
let obj;
try { obj = JSON.parse(line); } catch (e) { return; } // ignore keepalives
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) {
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 { content: full.trim(), toolCalls };
}
// 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) || '';
const parts = [];
// The assistant's role/tone is editable (Vorlagen page); the sections below
// describe the runtime wiring (tools, date, profile) and stay in code.
parts.push(promptStore.get(ctx && ctx.prompts, 'chat', { name }));
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 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).');
}
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.');
}
return parts.join('\n\n');
}
module.exports = { isConfigured, streamChat, runChat, buildContextPrompt, OLLAMA_MODEL, MAX_TOOL_ROUNDS };