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