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>
This commit is contained in:
+115
-72
@@ -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 };
|
||||
module.exports = { isConfigured, streamChat, runChat, buildContextPrompt, OLLAMA_MODEL, MAX_TOOL_ROUNDS };
|
||||
Reference in New Issue
Block a user