Enable automatic web search by default; improve toggle behavior and search planning.
This commit is contained in:
+63
-10
@@ -7,7 +7,7 @@ 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, decideWebSearch, selectSearchResults, retrieveWebSearch } from "../src/services/webSearchService.js";
|
||||
import { SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, planWebSearch, selectSearchResults, retrieveWebSearch } from "../src/services/webSearchService.js";
|
||||
import { createModelService } from "../src/services/modelService.js";
|
||||
import { formatChatMessage } from "../src/services/chatMessageHelpers.js";
|
||||
import { appendSources, createWebSearchToggle } from "../public/js/webSearch.js";
|
||||
@@ -81,6 +81,55 @@ test("search decisions and source selection use only relevant, renumbered eviden
|
||||
]);
|
||||
});
|
||||
|
||||
test("the model can choose research beyond keywords and skip keyword matches", async () => {
|
||||
let planningRequest;
|
||||
const history = [{role: "user", content: "I am choosing a Swiss rail pass."}];
|
||||
const modelService = {async generateReply(model, messages, options) {
|
||||
planningRequest = {model, messages, options};
|
||||
return '{"search":true,"query":"Swiss rail pass visitor comparison"}';
|
||||
}};
|
||||
let query;
|
||||
const outcome = await retrieveWebSearch("Which one suits me?", {async search(value) {
|
||||
query = value;
|
||||
return [{title: "Swiss rail pass comparison", url: "https://example.org/passes", snippet: "Compare Swiss rail pass options for visitors and their trips."}];
|
||||
}}, {modelService, model: "selected:model", history, referenceText: ""});
|
||||
assert.equal(outcome.state, "succeeded");
|
||||
assert.equal(query, "Swiss rail pass visitor comparison");
|
||||
assert.equal(planningRequest.model, "selected:model");
|
||||
assert.deepEqual(JSON.parse(planningRequest.messages.at(-1).content).history, history);
|
||||
assert.equal(planningRequest.options.reasoning, false);
|
||||
assert.equal(planningRequest.options.format.properties.search.type, "boolean");
|
||||
|
||||
const skipped = await retrieveWebSearch("Write a fictional breaking news story", {search() { assert.fail("The model chose not to search"); }}, {
|
||||
modelService: {async generateReply() { return '{"search":false,"query":""}'; }}, model: "selected:model"
|
||||
});
|
||||
assert.equal(skipped.state, "skipped");
|
||||
assert.equal(skipped.sources, null);
|
||||
const explicit = await planWebSearch("Search the web for Swiss rail passes", {
|
||||
modelService: {generateReply() { assert.fail("Explicit searches do not need planning"); }}
|
||||
});
|
||||
assert.equal(explicit.shouldSearch, true);
|
||||
});
|
||||
|
||||
test("search planning handles invalid output and failure without swallowing cancellation", async () => {
|
||||
for (const response of ["not JSON", "null", '{"search":"yes","query":"wrong"}', '{"search":true,"query":""}']) {
|
||||
const modelService = {async generateReply() { return response; }};
|
||||
assert.equal((await planWebSearch("latest news", {modelService})).shouldSearch, true);
|
||||
assert.equal((await planWebSearch("hello", {modelService})).shouldSearch, false);
|
||||
}
|
||||
assert.equal((await planWebSearch("hello", {modelService: {async generateReply() { throw new Error("Offline"); }}})).shouldSearch, false);
|
||||
const controller = new AbortController();
|
||||
await assert.rejects(planWebSearch("hello", {signal: controller.signal, modelService: {async generateReply(_model, _messages, {signal}) {
|
||||
controller.abort();
|
||||
assert.equal(signal.aborted, true);
|
||||
return '{"search":true,"query":"unused"}';
|
||||
}}}), {name: "AbortError"});
|
||||
const unavailable = await retrieveWebSearch("hello", new SearXNGSearchProvider({searxngUrl: ""}), {
|
||||
modelService: {generateReply() { assert.fail("Do not plan use of an unconfigured search provider"); }}
|
||||
});
|
||||
assert.equal(unavailable.state, "skipped");
|
||||
});
|
||||
|
||||
test("every Ollama request includes dynamic server date context", async () => {
|
||||
let requestBody;
|
||||
const modelService = createModelService({
|
||||
@@ -147,7 +196,13 @@ test("chat search integrates with streaming, persistence, personas, settings, re
|
||||
});
|
||||
const calls = [];
|
||||
const modelService = {
|
||||
async generateReply(model, messages, options) { calls.push({ model, messages, options }); return "A fact [1]."; },
|
||||
async generateReply(model, messages, options) {
|
||||
if (options.format?.properties?.search) {
|
||||
const {message} = JSON.parse(messages.at(-1).content);
|
||||
return JSON.stringify({search: decideWebSearch(message).shouldSearch, query: message});
|
||||
}
|
||||
calls.push({ model, messages, options }); return "A fact [1].";
|
||||
},
|
||||
async streamReply(model, messages, onChunk, options) {
|
||||
calls.push({ model, messages, options });
|
||||
onChunk("A fact ", "A fact ");
|
||||
@@ -222,7 +277,7 @@ test("chat search integrates with streaming, persistence, personas, settings, re
|
||||
assert.equal(messages[0].sources, undefined);
|
||||
assert.deepEqual(messages.at(-1).sources, [source]);
|
||||
const botId = messages.at(-1).id;
|
||||
const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {});
|
||||
const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {webSearch: false});
|
||||
assert.equal(retry.status, 200);
|
||||
assert.deepEqual(retry.body.message.sources, []);
|
||||
await api(`/chats/${chatId}/messages/${botId}/versions/0`, {});
|
||||
@@ -379,23 +434,21 @@ class Element {
|
||||
get childElementCount() { return this.children.length; }
|
||||
}
|
||||
|
||||
test("composer toggle resets and source renderer supports old messages and safe numbered links", (t) => {
|
||||
test("composer search defaults on and source renderer supports old messages and safe numbered links", (t) => {
|
||||
const original = globalThis.document;
|
||||
globalThis.document = { createElement: (tag) => new Element(tag) };
|
||||
t.after(() => { if (original === undefined) delete globalThis.document; else globalThis.document = original; });
|
||||
const button = new Element("button");
|
||||
const toggle = createWebSearchToggle(button);
|
||||
assert.equal(toggle.enabled, false);
|
||||
assert.equal(button.title, "Search the web");
|
||||
button.listeners.click();
|
||||
assert.equal(toggle.enabled, true);
|
||||
assert.equal(button.attributes["aria-pressed"], "true");
|
||||
assert.equal(button.title, "Web search enabled");
|
||||
assert.match(button.title, /Automatic web search on/);
|
||||
button.listeners.click();
|
||||
assert.equal(toggle.enabled, false);
|
||||
assert.equal(button.attributes["aria-pressed"], "false");
|
||||
assert.match(button.title, /Web search off/);
|
||||
button.listeners.click();
|
||||
toggle.reset();
|
||||
assert.equal(toggle.enabled, false);
|
||||
assert.equal(toggle.enabled, true);
|
||||
const container = new Element("div");
|
||||
appendSources(container, undefined);
|
||||
appendSources(container, []);
|
||||
|
||||
Reference in New Issue
Block a user