Files
jobbi-bewerbung/lib/chat.js
T
thomasandClaude 0c7502cfd7 KI-Chat: Bewerbungsdaten per Tool-Calling nachschlagen statt in Prompt stopfen
Der Systemprompt enthielt früher nur die 12 jüngsten Bewerbungen (LIMIT 12),
daher fand die KI ältere Firmen wie QBS KLIMTAX nicht. Alle Bewerbungen in den
Prompt zu laden machte den Chat sehr langsam, da der Prompt bei jedem Turn
voll neu verarbeitet wird.

Jetzt nutzt der Assistent Ollama-Tool-Calling, um Datenbankdaten on demand
nachzuschlagen. Der Systemprompt bleibt klein und konstant (nur Profil +
Datum), unabhängig von der Anzahl der Bewerbungen.

- lib/chat.js: streamChat mit tools + runChat-Tool-Schleife (max 4 Runden)
- server.js: Tools suche_bewerbungen/list_bewerbungen/bewerbung_detail/
  kommende_termine mit SQL-Queries; gatherChatContext auf Kern reduziert
- public/js/chat.js: Tool-Indikator ("durchsucht Bewerbungen…") im UI

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 05:41:14 +02:00

225 lines
9.0 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 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 = [];
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.'
);
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 };