diff --git a/lib/chat.js b/lib/chat.js index 2eecbe4..fd9d985 100644 --- a/lib/chat.js +++ b/lib/chat.js @@ -13,7 +13,10 @@ const promptStore = require('./prompts'); 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() { return Boolean(config.ollama().apiKey); @@ -194,7 +197,20 @@ function buildContextPrompt(ctx) { '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).' + '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) { parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' + diff --git a/lib/websuche.js b/lib/websuche.js new file mode 100644 index 0000000..f3078ab --- /dev/null +++ b/lib/websuche.js @@ -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 }; diff --git a/public/js/chat.js b/public/js/chat.js index 6e73a89..97d7cc5 100644 --- a/public/js/chat.js +++ b/public/js/chat.js @@ -28,17 +28,37 @@ // 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 // of characters that could break out of the href attribute. + function link(url, label) { + const safe = String(url).trim().replace(/["'<>`\\]/g, ''); + return `${label}`; + } + function inlineFmt(t) { - return t + // Fertige -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, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2') .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => { const u = String(url).trim(); if (u && !/^(https?:|mailto:|\/|#)/i.test(u)) return m; - const safe = u.replace(/["'<>`\\]/g, ''); - return `${label}`; + return park(link(u, label)); }); + + // 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) { diff --git a/server.js b/server.js index bc9e13f..cdfc752 100644 --- a/server.js +++ b/server.js @@ -34,6 +34,7 @@ const { DOKUMENT_TYPEN, normalizeDokumente, } = require('./lib/documents'); const chat = require('./lib/chat'); +const websuche = require('./lib/websuche'); const promptStore = require('./lib/prompts'); const designStore = require('./lib/design'); const mailer = require('./lib/mailer'); @@ -3932,6 +3933,35 @@ initializeDatabase().then(async () => { 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. @@ -3940,8 +3970,23 @@ initializeDatabase().then(async () => { list_bewerbungen: 'Bewerbungen werden geladen…', bewerbung_detail: 'Bewerbungsdetails 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 // value that is fed back to the model as the tool result. 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 }; } @@ -4156,7 +4210,7 @@ initializeDatabase().then(async () => { 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 }), + onToolCall: (name, args) => send({ type: 'tool', name, label: chatToolLabel(name, args) }), executeTool: executeChatTool, }); } catch (err) {