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:
2026-07-09 05:41:14 +02:00
co-authored by Claude
parent 306026b0a1
commit 0c7502cfd7
3 changed files with 268 additions and 140 deletions
+115 -72
View File
@@ -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 };
+11 -1
View File
@@ -243,7 +243,17 @@
}
await consumeSSE(res, (ev) => {
if (ev.type === 'token') {
if (ev.type === 'tool') {
// Tool lookups run before the first text token; surface a muted hint
// inside the typing indicator so the user sees activity.
const typingEl = document.getElementById('typing');
if (typingEl && !assistantBubble) {
const label = ev.label || ev.name || 'nachschlagen…';
typingEl.innerHTML = '<div class="rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-400 text-sm">' +
'🔍 ' + escapeHtml(label) + '</div>';
}
scrollBottom();
} else if (ev.type === 'token') {
if (!assistantBubble) {
if (typing) typing.remove();
assistantBubble = addMessage('assistant', '');
+142 -67
View File
@@ -2430,76 +2430,148 @@ initializeDatabase().then(() => {
// Gated behind OLLAMA_API_KEY. Threads + messages persist in SQLite; the
// assistant answer is streamed back via Server-Sent Events.
async function gatherChatContext() {
const [settings, upcoming] = await Promise.all([
const [settings, profilRows] = await Promise.all([
dbGet('SELECT name FROM settings WHERE id = 1'),
upcomingTermine(8),
dbAll(
`SELECT inhalt FROM basis_dokumente
WHERE typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != ''
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END`
),
]);
// Relevant bewerbungen: those tied to upcoming appointments, plus the most
// recent ones. Detailed context (job description, correspondence) is loaded
// for these so the assistant can answer firmen-specific questions.
const terminIds = upcoming.map((t) => t.bewerbung_id).filter(Boolean);
const recent = await dbAll(
'SELECT id FROM bewerbungen ORDER BY datum DESC, created_at DESC LIMIT 12'
);
const idSet = new Set([...terminIds, ...recent.map((r) => r.id)]);
const ids = [...idSet];
const apps = ids.length
? await dbAll(
`SELECT b.id, b.firma, b.stelle, b.status, b.datum, b.ort, b.notizen,
b.interne_notizen, b.stellenbeschreibung, b.quelle_url,
j.kontakt_email AS ja_kontakt, j.ansprechpartner AS ja_ansprech, j.beschreibung AS ja_beschreibung
FROM bewerbungen b
LEFT JOIN jobangebote j ON j.verknuepfte_bewerbung_id = b.id
WHERE b.id IN (${ids.map(() => '?').join(',')})
ORDER BY b.datum DESC, b.created_at DESC`,
ids
)
: [];
// Per-application correspondence (subjects only), newest first per app.
const emailsByApp = new Map();
if (ids.length) {
const emRows = await dbAll(
`SELECT bewerbung_id, direction, subject, from_addr
FROM emails
WHERE bewerbung_id IN (${ids.map(() => '?').join(',')})
ORDER BY bewerbung_id, email_date DESC, created_at DESC`,
ids
);
for (const e of emRows) {
if (!emailsByApp.has(e.bewerbung_id)) emailsByApp.set(e.bewerbung_id, []);
const arr = emailsByApp.get(e.bewerbung_id);
if (arr.length < 5) arr.push({ direction: e.direction, subject: e.subject, from: e.from_addr });
}
}
// The user's own profile (Lebenslauf / Kurzprofil) so the assistant can
// relate job descriptions back to the applicant's background.
const profilRows = await dbAll(
`SELECT inhalt FROM basis_dokumente
WHERE typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != ''
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END`
);
// Lightweight core context only: name, date and the user's profile (static,
// small). All application/appointment data is fetched on demand via tools,
// so the system prompt stays tiny regardless of how many bewerbungen exist.
const profil = (profilRows.map((r) => (r.inhalt || '').trim()).join('\n\n---\n\n')).slice(0, 1800);
const heute = new Date().toLocaleDateString('de-DE', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
return {
heute,
profil,
settings: settings || {},
applications: apps.map((a) => ({
id: a.id,
firma: a.firma, stelle: a.stelle, status: a.status, datum: a.datum, ort: a.ort,
quelle_url: a.quelle_url,
notizen: (a.notizen || '').trim().slice(0, 240),
interne_notizen: (a.interne_notizen || '').trim().slice(0, 240),
stellenbeschreibung: (a.stellenbeschreibung || a.ja_beschreibung || '').trim().slice(0, 900),
kontakt: a.ja_kontakt || null,
ansprechpartner: a.ja_ansprech || null,
korrespondenz: emailsByApp.get(a.id) || [],
})),
upcomingTermine: upcoming.map((t) => ({
titel: t.titel, start: t.start, bewerbung_id: t.bewerbung_id, bewerbung: t.bewerbung_firma,
})),
};
return { heute, profil, settings: settings || {} };
}
// Ollama tool definitions the assistant can call to look up application data.
const CHAT_TOOLS = [
{
type: 'function',
function: {
name: 'suche_bewerbungen',
description: 'Durchsucht Bewerbungen nach Firmen- oder Stellenname (Teiltreffer). Nutze dies, wenn der Nutzer eine konkrete Firma/Stelle nennt oder fragt, was zu einer Firma bekannt ist. Liefert eine kompakte Trefferliste (id, firma, stelle, status, datum, ort) — hole Details mit bewerbung_detail.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Suchbegriff, z. B. Firmen- oder Stellenname (mind. 2 Zeichen)' },
},
required: ['query'],
},
},
},
{
type: 'function',
function: {
name: 'list_bewerbungen',
description: 'Listet Bewerbungen auf, standardmäßig die jüngsten. Optional nach Status gefiltert. Für einen Überblick über alle laufenden/abgeschlossenen Bewerbungen.',
parameters: {
type: 'object',
properties: {
status: { type: 'string', description: 'Optional: nur Bewerbungen mit diesem Status (z. B. offen, absage, eingeladen)' },
limit: { type: 'integer', description: 'Max. Anzahl Treffer (Standard 20, max 40)' },
},
},
},
},
{
type: 'function',
function: {
name: 'bewerbung_detail',
description: 'Liefert volle Details zu einer Bewerbung: Stellenbeschreibung, Notizen, interne Notizen, Kontakt, Quell-URL und die letzten Korrespondenz-Betreffe. Setze die id aus suche_bewerbungen/list_bewerbungen voraus.',
parameters: {
type: 'object',
properties: { id: { type: 'integer', description: 'Bewerbungs-ID' } },
required: ['id'],
},
},
},
{
type: 'function',
function: {
name: 'kommende_termine',
description: 'Liefert die nächsten Termine (Gespräche, Fristen) mit Titel, Startzeit (UTC-ISO), verknüpfter Bewerbung.',
parameters: { type: 'object', properties: {} },
},
},
];
// Tool labels shown in the UI while a tool call is in flight.
const CHAT_TOOL_LABELS = {
suche_bewerbungen: 'Bewerbungen werden durchsucht…',
list_bewerbungen: 'Bewerbungen werden geladen…',
bewerbung_detail: 'Bewerbungsdetails werden geladen…',
kommende_termine: 'Termine werden geladen…',
};
// Execute one tool call against the database. Returns a JSON-serialisable
// value that is fed back to the model as the tool result.
async function executeChatTool(name, args) {
const a = args || {};
if (name === 'suche_bewerbungen') {
const q = String(a.query || '').trim();
if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' };
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
const rows = await dbAll(
`SELECT id, firma, stelle, status, datum, ort FROM bewerbungen
WHERE firma LIKE ? ESCAPE '\\' OR stelle LIKE ? ESCAPE '\\'
ORDER BY datum DESC, created_at DESC LIMIT 20`,
[like, like]
);
return { treffer: rows };
}
if (name === 'list_bewerbungen') {
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 40);
const status = String(a.status || '').trim();
const sql = `SELECT id, firma, stelle, status, datum, ort FROM bewerbungen
${status ? 'WHERE status = ?' : ''} ORDER BY datum DESC, created_at DESC LIMIT ?`;
const rows = await dbAll(sql, status ? [status, limit] : [limit]);
return { bewerbungen: rows };
}
if (name === 'bewerbung_detail') {
const id = Number(a.id);
if (!id) return { error: 'keine id' };
const row = await dbGet(
`SELECT b.id, b.firma, b.stelle, b.status, b.datum, b.ort, b.notizen,
b.interne_notizen, b.stellenbeschreibung, b.quelle_url,
j.kontakt_email AS ja_kontakt, j.ansprechpartner AS ja_ansprech, j.beschreibung AS ja_beschreibung
FROM bewerbungen b
LEFT JOIN jobangebote j ON j.verknuepfte_bewerbung_id = b.id
WHERE b.id = ?`,
[id]
);
if (!row) return { error: 'nicht gefunden' };
const em = await dbAll(
`SELECT direction, subject, from_addr FROM emails WHERE bewerbung_id = ?
ORDER BY email_date DESC, created_at DESC LIMIT 8`,
[id]
);
return {
id: row.id, firma: row.firma, stelle: row.stelle, status: row.status, datum: row.datum, ort: row.ort,
quelle_url: row.quelle_url,
kontakt_email: row.ja_kontakt || null,
ansprechpartner: row.ja_ansprech || null,
notizen: (row.notizen || '').trim(),
interne_notizen: (row.interne_notizen || '').trim(),
stellenbeschreibung: (row.stellenbeschreibung || row.ja_beschreibung || '').trim().slice(0, 1200),
korrespondenz: em.map((e) => ({
direction: e.direction, subject: e.subject, von: e.from_addr,
})),
};
}
if (name === 'kommende_termine') {
const rows = await upcomingTermine(10);
return {
termine: rows.map((t) => ({
titel: t.titel, start: t.start,
bewerbung_id: t.bewerbung_id,
bewerbung: t.bewerbung_firma || null,
})),
};
}
return { error: 'unbekanntes Werkzeug: ' + name };
}
// Chat page: list threads + render the active thread (or a fresh empty one).
@@ -2631,11 +2703,14 @@ initializeDatabase().then(() => {
let assistantText = '';
try {
assistantText = await chat.streamChat({
assistantText = await chat.runChat({
system,
messages,
tools: CHAT_TOOLS,
signal: controller.signal,
onToken: (delta) => send({ type: 'token', content: delta }),
onToolCall: (name) => send({ type: 'tool', name, label: CHAT_TOOL_LABELS[name] || name }),
executeTool: executeChatTool,
});
} catch (err) {
if (aborted) { res.end(); return; }