KI-Chat: Web-Recherche ueber Ollama Web Search
Der Assistent kannte bisher nur die Datenbank. Fragen wie "Was muss ich ueber die Firma wissen?" konnte er damit nicht beantworten - Groesse, Produkte, News, Kultur stehen nirgends in der Bewerbung. Zwei neue Werkzeuge (web_suche, web_seite_lesen) ueber die Web-Search-API von ollama.com. Sie authentifizieren sich mit demselben OLLAMA_API_KEY, den der Benutzer fuer Chat und Dokumente ohnehin hinterlegt hat: keine zusaetzliche Konfiguration. Anders als /api/chat haengt die Websuche nicht an OLLAMA_HOST - wer rein lokal chattet, hat schlicht keine Websuche, der Rest laeuft unveraendert weiter. - Tool-Runden 4 -> 8: Bewerbung holen, suchen, Seiten lesen, antworten lief vorher ins Limit, statt zu antworten. - Antworten der Websuche hart gedeckelt, sie wandern sonst in jeder weiteren Runde erneut ins Kontextfenster. - Status-Label zeigt, wonach gesucht wird - sonst sieht der Nutzer nicht, ob die richtige Firma erwischt wurde. - Nackte URLs im Chat sind klickbar: Belege nennt das Modell meist als reine URL, nicht als Markdown-Link. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+18
-2
@@ -13,7 +13,10 @@
|
|||||||
const promptStore = require('./prompts');
|
const promptStore = require('./prompts');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
|
|
||||||
const MAX_TOOL_ROUNDS = 4;
|
// Firmenrecherche braucht mehr Runden als eine reine Datenbankfrage: Bewerbung
|
||||||
|
// holen -> web_suche -> ein, zwei Seiten lesen -> antworten. Mit 4 Runden lief
|
||||||
|
// der Assistent dabei ins Limit, statt zu antworten.
|
||||||
|
const MAX_TOOL_ROUNDS = 8;
|
||||||
|
|
||||||
function isConfigured() {
|
function isConfigured() {
|
||||||
return Boolean(config.ollama().apiKey);
|
return Boolean(config.ollama().apiKey);
|
||||||
@@ -194,7 +197,20 @@ function buildContextPrompt(ctx) {
|
|||||||
'Korrespondenz oder einem Termin fragt — rufe niemals Daten aus dem Gedächtnis ' +
|
'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 ' +
|
'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 ' +
|
'einer Runde parallel an. Wenn eine Suche keine Treffer liefert, weise den ' +
|
||||||
'Nutzer darauf hin und frage nach Details (z. B. Link/Stellenbeschreibung).'
|
'Nutzer darauf hin und frage nach Details (z. B. Link/Stellenbeschreibung).\n\n' +
|
||||||
|
'Zusätzlich kannst du mit `web_suche` und `web_seite_lesen` im Internet ' +
|
||||||
|
'recherchieren. Nutze das für alles, was nicht in den Bewerbungsdaten steht: ' +
|
||||||
|
'Hintergrund zu einer Firma (Produkte, Größe, Standorte, Kultur, aktuelle ' +
|
||||||
|
'Nachrichten), Gehaltsspannen, Technologien, Vorbereitung auf ein Gespräch. ' +
|
||||||
|
'Typischer Ablauf bei „Was muss ich über Firma X wissen?": erst die Bewerbung ' +
|
||||||
|
'per Werkzeug holen (Stelle, Ort, Stellenbeschreibung, quelle_url), dann ' +
|
||||||
|
'gezielt im Web nachrecherchieren und beides zusammenführen. Recherchiere ' +
|
||||||
|
'nicht ungefragt bei jeder Frage — nur, wenn aktuelles oder externes Wissen ' +
|
||||||
|
'die Antwort wirklich besser macht.\n\n' +
|
||||||
|
'Bei Web-Rechercheergebnissen: Gib nur wieder, was in den Treffern steht, und ' +
|
||||||
|
'nenne die Quelle als URL. Wenn du etwas nicht belegen kannst, sage es — rate ' +
|
||||||
|
'nicht. Achte darauf, dass Treffer wirklich die gemeinte Firma betreffen ' +
|
||||||
|
'(Namensdopplungen, falscher Standort) und weise auf Zweifel hin.'
|
||||||
);
|
);
|
||||||
if (heute) {
|
if (heute) {
|
||||||
parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' +
|
parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' +
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Web-Recherche für den KI-Chat (Ollama Web Search API).
|
||||||
|
//
|
||||||
|
// Der Chat-Assistent kennt bisher nur, was in der Datenbank steht. Für Fragen
|
||||||
|
// wie „Was muss ich über die Firma wissen?" fehlt ihm alles, was nicht in der
|
||||||
|
// Stellenbeschreibung steht — Größe, Produkte, News, Kultur. Diese beiden
|
||||||
|
// Funktionen geben ihm einen Blick nach draußen:
|
||||||
|
//
|
||||||
|
// suche(query) -> Trefferliste (Titel, URL, Auszug)
|
||||||
|
// seiteLesen(url) -> Volltext einer Seite (z. B. die „Über uns"-Seite)
|
||||||
|
//
|
||||||
|
// Beides läuft über ollama.com und authentifiziert sich mit demselben
|
||||||
|
// OLLAMA_API_KEY, den der Benutzer für Chat und Dokumentenerzeugung hinterlegt
|
||||||
|
// hat — es ist also keine zusätzliche Konfiguration nötig. Anders als /api/chat
|
||||||
|
// ist die Websuche ein reiner Cloud-Dienst: Sie hängt NICHT an OLLAMA_HOST.
|
||||||
|
// Wer lokal gegen http://localhost:11434 chattet, aber keinen ollama.com-Key
|
||||||
|
// hinterlegt hat, hat schlicht keine Websuche (isConfigured() -> false).
|
||||||
|
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const HOST = 'https://ollama.com';
|
||||||
|
const TIMEOUT_MS = 30000;
|
||||||
|
// Antworten landen im Kontextfenster des Modells und werden bei jeder weiteren
|
||||||
|
// Runde erneut mitgeschickt. Deshalb hart deckeln: lieber knappe, brauchbare
|
||||||
|
// Auszüge als eine komplette Seite, die den Chat langsam und teuer macht.
|
||||||
|
const MAX_SNIPPET = 1200;
|
||||||
|
const MAX_SEITE = 6000;
|
||||||
|
|
||||||
|
function isConfigured() {
|
||||||
|
return Boolean(config.ollama().apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(pfad, body) {
|
||||||
|
const { apiKey } = config.ollama();
|
||||||
|
if (!apiKey) throw new Error('Websuche nicht verfügbar: kein Ollama-API-Schlüssel konfiguriert.');
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${HOST}${pfad}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') throw new Error('Websuche: Zeitüberschreitung.');
|
||||||
|
throw new Error(`Websuche fehlgeschlagen: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Websuche antwortete mit ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function kuerzen(text, max) {
|
||||||
|
const t = String(text || '').replace(/\s+\n/g, '\n').trim();
|
||||||
|
return t.length > max ? `${t.slice(0, max)}…` : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volltextsuche im Web. `maxResults` deckelt Ollama selbst bei 10.
|
||||||
|
async function suche(query, maxResults = 5) {
|
||||||
|
const q = String(query || '').trim();
|
||||||
|
if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' };
|
||||||
|
const limit = Math.min(Math.max(Number(maxResults) || 5, 1), 10);
|
||||||
|
const data = await post('/api/web_search', { query: q, max_results: limit });
|
||||||
|
const results = Array.isArray(data && data.results) ? data.results : [];
|
||||||
|
return {
|
||||||
|
treffer: results.map((r) => ({
|
||||||
|
titel: r.title || '',
|
||||||
|
url: r.url || '',
|
||||||
|
auszug: kuerzen(r.content, MAX_SNIPPET),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine konkrete Seite lesen (Stellenanzeige, Karriere-/Über-uns-Seite, Presse).
|
||||||
|
async function seiteLesen(url) {
|
||||||
|
const u = String(url || '').trim();
|
||||||
|
if (!/^https?:\/\//i.test(u)) throw new Error('Ungültige URL (muss mit http:// oder https:// beginnen).');
|
||||||
|
const data = await post('/api/web_fetch', { url: u });
|
||||||
|
return {
|
||||||
|
url: u,
|
||||||
|
titel: (data && data.title) || '',
|
||||||
|
inhalt: kuerzen(data && data.content, MAX_SEITE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isConfigured, suche, seiteLesen };
|
||||||
+23
-3
@@ -28,17 +28,37 @@
|
|||||||
// targets are restricted to http(s)/mailto/relative so a `javascript:` URL
|
// targets are restricted to http(s)/mailto/relative so a `javascript:` URL
|
||||||
// from the model cannot become a clickable XSS vector, and the URL is stripped
|
// from the model cannot become a clickable XSS vector, and the URL is stripped
|
||||||
// of characters that could break out of the href attribute.
|
// of characters that could break out of the href attribute.
|
||||||
|
function link(url, label) {
|
||||||
|
const safe = String(url).trim().replace(/["'<>`\\]/g, '');
|
||||||
|
return `<a href="${safe}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
function inlineFmt(t) {
|
function inlineFmt(t) {
|
||||||
return t
|
// Fertige <a>-Tags werden geparkt, damit die Autolink-Regel unten nicht in
|
||||||
|
// eine bereits gebaute href hineinläuft und den Tag zerlegt.
|
||||||
|
const parked = [];
|
||||||
|
const park = (html) => `\u0000${parked.push(html) - 1}\u0000`;
|
||||||
|
|
||||||
|
let s = t
|
||||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
||||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
|
||||||
const u = String(url).trim();
|
const u = String(url).trim();
|
||||||
if (u && !/^(https?:|mailto:|\/|#)/i.test(u)) return m;
|
if (u && !/^(https?:|mailto:|\/|#)/i.test(u)) return m;
|
||||||
const safe = u.replace(/["'<>`\\]/g, '');
|
return park(link(u, label));
|
||||||
return `<a href="${safe}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nackte Quellen-URLs klickbar machen — bei Web-Recherchen nennt der
|
||||||
|
// Assistent Belege oft als reine URL, nicht als Markdown-Link. Nur http(s),
|
||||||
|
// und Satzzeichen am Ende gehören zum Satz, nicht zur Adresse.
|
||||||
|
s = s.replace(/https?:\/\/[^\s<>"'`\u0000]+/g, (u) => {
|
||||||
|
const m = u.match(/[.,;:!?)\]]+$/);
|
||||||
|
const rein = m ? u.slice(0, -m[0].length) : u;
|
||||||
|
return park(link(rein, rein)) + (m ? m[0] : '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return s.replace(/\u0000(\d+)\u0000/g, (m, i) => parked[Number(i)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(src) {
|
function renderMarkdown(src) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const {
|
|||||||
DOKUMENT_TYPEN, normalizeDokumente,
|
DOKUMENT_TYPEN, normalizeDokumente,
|
||||||
} = require('./lib/documents');
|
} = require('./lib/documents');
|
||||||
const chat = require('./lib/chat');
|
const chat = require('./lib/chat');
|
||||||
|
const websuche = require('./lib/websuche');
|
||||||
const promptStore = require('./lib/prompts');
|
const promptStore = require('./lib/prompts');
|
||||||
const designStore = require('./lib/design');
|
const designStore = require('./lib/design');
|
||||||
const mailer = require('./lib/mailer');
|
const mailer = require('./lib/mailer');
|
||||||
@@ -3932,6 +3933,35 @@ initializeDatabase().then(async () => {
|
|||||||
parameters: { type: 'object', properties: {} },
|
parameters: { type: 'object', properties: {} },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'web_suche',
|
||||||
|
description: 'Sucht im Internet und liefert Treffer mit Titel, URL und Auszug. Nutze dies für alles, was NICHT in der Bewerbungsdatenbank steht: Hintergrund zu einer Firma (Größe, Produkte, Standorte, Kultur, aktuelle News, Geschäftsberichte, Bewertungen), Gehaltsspannen, Branchen- oder Technologiefragen, Vorbereitung auf ein Vorstellungsgespräch. Hole dir Firmenname/Stelle zuerst per bewerbung_detail aus der Datenbank und suche dann gezielt danach.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'Suchanfrage in natürlicher Sprache, möglichst konkret (z. B. „Muster GmbH München Mitarbeiterzahl Produkte 2026").' },
|
||||||
|
max_results: { type: 'integer', description: 'Anzahl Treffer (Standard 5, max 10).' },
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'web_seite_lesen',
|
||||||
|
description: 'Lädt eine konkrete Webseite und liefert ihren Text. Nutze dies, wenn ein Treffer aus web_suche vertieft werden soll (z. B. die „Über uns"-/Karriereseite der Firma) oder wenn der Nutzer bzw. die Bewerbung eine URL nennt (quelle_url der Stellenanzeige).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', description: 'Vollständige URL inkl. https://' },
|
||||||
|
},
|
||||||
|
required: ['url'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tool labels shown in the UI while a tool call is in flight.
|
// Tool labels shown in the UI while a tool call is in flight.
|
||||||
@@ -3940,8 +3970,23 @@ initializeDatabase().then(async () => {
|
|||||||
list_bewerbungen: 'Bewerbungen werden geladen…',
|
list_bewerbungen: 'Bewerbungen werden geladen…',
|
||||||
bewerbung_detail: 'Bewerbungsdetails werden geladen…',
|
bewerbung_detail: 'Bewerbungsdetails werden geladen…',
|
||||||
kommende_termine: 'Termine werden geladen…',
|
kommende_termine: 'Termine werden geladen…',
|
||||||
|
web_suche: 'Im Internet wird recherchiert…',
|
||||||
|
web_seite_lesen: 'Webseite wird gelesen…',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bei der Web-Recherche zeigt das Label mit an, wonach gesucht bzw. was geladen
|
||||||
|
// wird — sonst steht der Nutzer vor einem stummen „recherchiert…", ohne zu
|
||||||
|
// sehen, ob der Assistent die richtige Firma erwischt hat.
|
||||||
|
function chatToolLabel(name, args) {
|
||||||
|
const basis = CHAT_TOOL_LABELS[name] || name;
|
||||||
|
const a = args || {};
|
||||||
|
if (name === 'web_suche' && a.query) return `Im Internet wird recherchiert: „${String(a.query).slice(0, 80)}"`;
|
||||||
|
if (name === 'web_seite_lesen' && a.url) {
|
||||||
|
try { return `Webseite wird gelesen: ${new URL(String(a.url)).hostname}`; } catch (e) { /* Label ohne Host */ }
|
||||||
|
}
|
||||||
|
return basis;
|
||||||
|
}
|
||||||
|
|
||||||
// Execute one tool call against the database. Returns a JSON-serialisable
|
// Execute one tool call against the database. Returns a JSON-serialisable
|
||||||
// value that is fed back to the model as the tool result.
|
// value that is fed back to the model as the tool result.
|
||||||
async function executeChatTool(name, args) {
|
async function executeChatTool(name, args) {
|
||||||
@@ -4015,6 +4060,15 @@ initializeDatabase().then(async () => {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Web-Recherche (Ollama Web Search) — Fehler werden als Tool-Ergebnis
|
||||||
|
// zurückgegeben (runChat fängt sie ohnehin ab), damit das Modell einen
|
||||||
|
// Ausfall der Websuche dem Nutzer sagen kann, statt den Chat abzubrechen.
|
||||||
|
if (name === 'web_suche') {
|
||||||
|
return websuche.suche(a.query, a.max_results);
|
||||||
|
}
|
||||||
|
if (name === 'web_seite_lesen') {
|
||||||
|
return websuche.seiteLesen(a.url);
|
||||||
|
}
|
||||||
return { error: 'unbekanntes Werkzeug: ' + name };
|
return { error: 'unbekanntes Werkzeug: ' + name };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4156,7 +4210,7 @@ initializeDatabase().then(async () => {
|
|||||||
tools: CHAT_TOOLS,
|
tools: CHAT_TOOLS,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
onToken: (delta) => send({ type: 'token', content: delta }),
|
onToken: (delta) => send({ type: 'token', content: delta }),
|
||||||
onToolCall: (name) => send({ type: 'tool', name, label: CHAT_TOOL_LABELS[name] || name }),
|
onToolCall: (name, args) => send({ type: 'tool', name, label: chatToolLabel(name, args) }),
|
||||||
executeTool: executeChatTool,
|
executeTool: executeChatTool,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user