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
+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; }