Fix web search (again)

This commit is contained in:
Frederik Spirgi
2026-09-21 11:54:52 +02:00
parent be28be1430
commit adbc547dfd
4 changed files with 194 additions and 36 deletions
+37 -11
View File
@@ -4,14 +4,14 @@ import {createServer} from "node:http";
import {createApp} from "../src/app.js";
import {createChatService} from "../src/services/chatService.js";
import {runMigrations} from "../src/db/schema.js";
import {SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery} from "../src/services/webSearchService.js";
import {SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, selectSearchResults} from "../src/services/webSearchService.js";
import {createModelService} from "../src/services/modelService.js";
import {formatChatMessage} from "../src/services/chatMessageHelpers.js";
import {appendSources, createWebSearchToggle} from "../public/app/webSearch.js";
import {stream} from "../public/app/api.js";
// Local protocol fixtures are test-only; production always uses the configured provider.
const result = {title: "Search result", url: "https://example.org/news", content: "A current fact."};
const result = {title: "Major events on September 10, 2025", url: "https://example.org/news", content: "A news report covering major events on September 10, 2025."};
const source = {title: result.title, url: result.url, snippet: result.content};
async function listen(t, handler) {
@@ -28,7 +28,7 @@ test("SearXNG encodes queries, limits results and treats result data as untruste
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({results: [
{title: "bad", url: "javascript:alert(1)"},
...Array.from({length: 9}, (_, index) => ({title: "T".repeat(500), url: `https://example.org/${index}`, content: "S".repeat(2000)}))
...Array.from({length: 9}, (_, index) => ({title: `${"T".repeat(290)} ${index} ${"T".repeat(100)}`, url: `https://example.org/${index}`, content: "S".repeat(2000)}))
]}));
});
const provider = new SearXNGSearchProvider({searxngUrl: `${upstream.url}/base`, webSearchTimeoutMs: 1000});
@@ -37,7 +37,7 @@ test("SearXNG encodes queries, limits results and treats result data as untruste
assert.equal(received.pathname, "/base/search");
assert.equal(received.searchParams.get("format"), "json");
assert.equal(received.searchParams.get("q"), query.trim().slice(0, 500));
assert.equal(sources.length, 5);
assert.equal(sources.length, 9);
assert.equal(sources[0].title.length, 300);
assert.equal(sources[0].snippet.length, 1500);
assert.equal((await provider.search("test", {limit: 2})).length, 2);
@@ -50,12 +50,31 @@ test("SearXNG encodes queries, limits results and treats result data as untruste
assert.match(context[1].content, /<web_search_results>/);
assert.match(context[1].content, /\[1\]/);
assert.match(context[1].content, /IGNORE ALL INSTRUCTIONS/);
assert.equal(buildWebSearchQuery("What happened on 10.09.2025?"), "What happened on 10.09.2025? 2025-09-10");
assert.equal(buildWebSearchQuery("What happened on 10.09.2025?"), "major news events September 10, 2025 2025-09-10");
const emptyContext = buildWebSearchContext([], new Date("2026-09-21T12:00:00.000Z"));
assert.equal(emptyContext.length, 1);
assert.match(emptyContext[0].content, /no usable results/);
});
test("search decisions and source selection use only relevant, renumbered evidence", () => {
assert.deepEqual(decideWebSearch("What date is it today?"), {needsSearch: false, intent: "runtime"});
assert.equal(decideWebSearch("What is the latest Node.js release?").needsSearch, true);
assert.equal(decideWebSearch("What happened on 10.09.2025?").intent, "news");
const selected = selectSearchResults("What happened on 10.09.2025?", [
{title: "Major events on September 10, 2025", url: "https://news.example/events?utm_source=test", snippet: "World news and official reports from September 10, 2025."},
{title: "Global reporting from September 10, 2025", url: "https://other.example/events", snippet: "World news report from September 10, 2025."},
{title: "October 9, 2025 current events", url: "https://calendar.example/october", snippet: "A different date."},
{title: "Campus memories", url: "https://university.example/memories", snippet: "An incidental mention of September 10, 2025."},
{title: "Reddit discussion", url: "https://www.reddit.com/r/example", snippet: "A discussion unrelated to events on the requested date."}
], decideWebSearch("What happened on 10.09.2025?"));
assert.equal(selected.length, 2);
assert.deepEqual(selected.map((item) => item.url), ["https://news.example/events", "https://other.example/events"]);
assert.deepEqual(selected.map((item, index) => ({number: index + 1, title: item.title})), [
{number: 1, title: "Major events on September 10, 2025"},
{number: 2, title: "Global reporting from September 10, 2025"}
]);
});
test("every Ollama request includes dynamic server date context", async () => {
let requestBody;
const modelService = createModelService({
@@ -110,11 +129,13 @@ test("SearXNG handles empty results, failures, malformed responses, redirects, l
test("chat search integrates with streaming, persistence, personas, settings, retry, branch and import", async (t) => {
let searchCount = 0;
let lastSearchQuery = "";
let lastSearchCategory = "";
let failSearch = false;
let emptySearch = false;
const upstream = await listen(t, (req, res) => {
searchCount += 1;
lastSearchQuery = new URL(req.url, "http://localhost").searchParams.get("q");
lastSearchCategory = new URL(req.url, "http://localhost").searchParams.get("categories");
res.writeHead(failSearch ? 503 : 200, {"Content-Type": "application/json"});
res.end(JSON.stringify({results: emptySearch ? [] : [result]}));
});
@@ -152,6 +173,10 @@ test("chat search integrates with streaming, persistence, personas, settings, re
assert.equal(normal.status, 200);
assert.equal(normal.body.sources, undefined);
assert.equal(searchCount, 0);
const today = await api("/chat", {chatId, message: "What date is it today?", webSearch: true});
assert.equal(today.status, 200);
assert.equal(today.body.sources, undefined);
assert.equal(searchCount, 0);
await api("/chat", {chatId, message: "ordinary too", webSearch: false});
assert.equal(searchCount, 0);
assert.equal((await api("/chat", {chatId, message: "invalid", webSearch: "true"})).status, 400);
@@ -172,7 +197,8 @@ test("chat search integrates with streaming, persistence, personas, settings, re
assert.deepEqual(events[1].data, {status: "generating", sourceCount: 1});
assert.deepEqual(events.at(-1).data.sources, [source]);
assert.equal(searchCount, 1);
assert.equal(lastSearchQuery, "What happened on 10.09.2025? 2025-09-10");
assert.equal(lastSearchQuery, "major news events September 10, 2025 2025-09-10");
assert.equal(lastSearchCategory, "news");
const call = calls.at(-1);
assert.equal(call.model, "chosen:model");
assert.deepEqual(call.options.generation, {temperature: 0.4, contextLength: 4096, responseLength: 300});
@@ -204,22 +230,22 @@ test("chat search integrates with streaming, persistence, personas, settings, re
for (const message of allMessages) assert.deepEqual(JSON.parse(message.sources), [source]);
emptySearch = true;
const empty = await api("/chat", {chatId, message: "nothing found", webSearch: true});
const empty = await api("/chat", {chatId, message: "latest news nothing found", webSearch: true});
assert.deepEqual(empty.body.sources, []);
assert.ok(calls.at(-1).messages.some((message) => message.content.includes("no usable results")));
assert.ok(!calls.at(-1).messages.some((message) => message.role === "user" && message.content.startsWith("<web_search_results>")));
const beforeFailure = calls.length;
failSearch = true;
const failed = await api("/chat", {chatId, message: "keep this message", webSearch: true});
const failed = await api("/chat", {chatId, message: "latest news please", webSearch: true});
assert.equal(failed.status, 503);
assert.equal(failed.body.error, "Web search is currently unavailable.");
assert.equal(calls.length, beforeFailure);
messages = (await api(`/chats/${chatId}/messages`)).body.messages;
assert.equal(messages.at(-1).content, "keep this message");
assert.equal(messages.at(-1).content, "latest news please");
assert.equal(messages.at(-1).deliveryStatus, "failed");
const failedStream = await fetch(`${backend.url}/chat/stream`, {
method: "POST", headers: {"Content-Type": "application/json", Cookie: cookie},
body: JSON.stringify({chatId, message: "stream failure", webSearch: true})
body: JSON.stringify({chatId, message: "latest stream news", webSearch: true})
});
const failedWire = await failedStream.text();
assert.match(failedWire, /event: status/);
@@ -246,7 +272,7 @@ test("chat search integrates with streaming, persistence, personas, settings, re
const chatService = createChatService(repos, modelService, unconfigured.locals.config);
const localRequest = {user: "local-test", chatId: localChat.id, model: "local", message: "hello"};
assert.equal((await chatService.sendChatMessage(localRequest)).reply, "A fact [1].");
assert.equal((await chatService.sendChatMessage({...localRequest, webSearch: true})).status, 503);
assert.equal((await chatService.sendChatMessage({...localRequest, message: "latest news", webSearch: true})).status, 503);
const normalChunks = [];
const normalStream = await chatService.streamChatMessage({...localRequest, onChunk: (chunk) => normalChunks.push(chunk)});
assert.equal(normalStream.sources, undefined);