Files
jobbi-bewerbung/lib/websuche.js
T
thomasandClaude Opus 4.8 4a036fe215 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>
2026-07-14 12:02:29 +02:00

93 lines
3.5 KiB
JavaScript

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