improve reasoning toggle tests and refactor model capability checks
This commit is contained in:
@@ -105,7 +105,9 @@ Pure chat message, retry, memory, summary, and persona-state formatting lives in
|
||||
|
||||
The globe button next to the composer enables search for one message. It defaults to off and resets once the server accepts the message. The browser sends `webSearch: true` (or `false`) through the existing authenticated `POST /chat/stream`; `POST /chat` supports the same optional boolean. Omitting it preserves the normal chat flow. No generic tool/agent system is needed.
|
||||
|
||||
The existing chat service saves the user message, performs one server-side SearXNG search using the current message (whitespace normalized, at most 500 characters), then supplies up to five numbered results as a separate context alongside the existing history, model, personas and generation settings. Instructions require numbered citations, acknowledgement of insufficient results and treating all result content as untrusted data. This is a basic prompt-injection precaution, not a guarantee that a model will follow instructions. No result pages are fetched.
|
||||
Search uses `shouldSearch = webSearchEnabled && (explicitSearchIntent || automaticSearchDecision)`. Deterministic commands such as “search the internet”, “look this up online”, and “find sources” override smart skipping. Arithmetic, rewriting, explanations and questions answered by the server clock can otherwise skip search. Query cleanup removes command wording while preserving dates and years; “average lifespan” is normalized to “average life expectancy”.
|
||||
|
||||
The chat service retrieves up to 15 candidates per query and selects 1–5 useful sources dynamically, rejecting empty evidence and wrong-date results. SearXNG `content` maps to `snippet` (an existing non-empty snippet is also accepted). Exact-date event searches use the general index to include archives and try at most one alternate query if the first returns no usable evidence; candidates are merged and deduplicated. Current-news requests retain the news category. The same numbered selected titles, URLs and snippets go immediately before the current question in Ollama's context, into the response, and into storage. Retrieval finishes before either reasoning or answer streaming starts. Instructions require citations, supported partial answers rather than blanket claims of missing details, and honest disclosure when evidence is insufficient. Result content remains untrusted data; no result pages are fetched.
|
||||
|
||||
Set environment variables in the server/container environment, or load the example explicitly with `node --env-file=.env server.js` (the existing npm start command does not load `.env` automatically):
|
||||
|
||||
@@ -129,7 +131,7 @@ The provider calls `GET {SEARXNG_URL}/search?q=...&format=json` using encoded qu
|
||||
|
||||
The server-side `WebSearchProvider` JSDoc contract is `search(query, {signal, limit}?) -> Promise<SearchResult[]>`; `SearXNGSearchProvider` is the initial implementation. Results have `{title, url, snippet}`. This repository uses JavaScript ES modules, so types are documented with JSDoc rather than introducing a TypeScript build.
|
||||
|
||||
Search adds SSE `status` events: `{status: "searching"}` after message acceptance and `{status: "generating", sourceCount}` before model generation. Existing `chunk` events are unchanged. `done` adds optional `sources` to `{reply}`; the non-streaming response does likewise. Empty results produce `sources: []` and an explicit insufficient-results instruction. Failures produce the existing error event (HTTP 503 on `/chat`); no model answer is generated after search failure. The saved failed user message remains visible, and the composer restores its text for resending without search.
|
||||
Search emits `{status: "searching"}` only when retrieval actually runs, then `{status: "generating", searchState, sourceCount?}` before model generation. The UI initially says “Preparing response…” so skipped searches never claim to search. `searchState` distinguishes `skipped`, `no_results`, `no_relevant_results`, `succeeded`, and `failed`. Successful `done` events and non-streaming responses carry this state and optional `sources`; search failures carry `searchState: "failed"` in the error response. No usable evidence produces `sources: []` and instructions to say that a search found insufficient relevant evidence, without fake citations. Initial retrieval failures use the existing error event (HTTP 503 on `/chat`) and do not generate an answer; an optional fallback failure preserves the successful initial search. The saved failed user message remains visible, and the composer restores its text for resending without search.
|
||||
|
||||
Sources appear below assistant messages as numbered links. They persist in a nullable SQLite `chat_messages.sources` JSON column, added automatically on startup using the existing additive schema mechanism; no manual migration is needed. Old messages remain valid. Sources are preserved in JSON exports/imports and branches. Regenerating an answer reuses its stored results without performing another search; a fresh search requires sending another message with the toggle enabled.
|
||||
|
||||
|
||||
+4
-3
@@ -18,11 +18,12 @@ export const put = (url, data) => request(url, data, "PUT");
|
||||
export const del = (url) => request(url, null, "DELETE");
|
||||
|
||||
/**
|
||||
* @typedef {{event: "status", data: {status: "accepted"|"searching"|"generating", sourceCount?: number}}
|
||||
* @typedef {"skipped"|"failed"|"no_results"|"no_relevant_results"|"succeeded"} SearchState
|
||||
* @typedef {{event: "status", data: {status: "accepted"|"searching"|"generating", sourceCount?: number, searchState?: SearchState}}
|
||||
* | {event: "chunk", data: {chunk: string, fullReply: string}}
|
||||
* | {event: "thinking", data: {chunk: string, fullThinking: string}}
|
||||
* | {event: "done", data: {reply: string, sources?: import("./webSearch.js").WebSource[]}}
|
||||
* | {event: "error", data: {error: string, code?: string}}} ChatStreamEvent
|
||||
* | {event: "done", data: {reply: string, sources?: import("./webSearch.js").WebSource[], searchState?: SearchState}}
|
||||
* | {event: "error", data: {error: string, code?: string, searchState?: SearchState}}} ChatStreamEvent
|
||||
*/
|
||||
export async function stream(url, data, handlers = {}, signal = undefined) {
|
||||
const response = await fetch(url, {
|
||||
|
||||
+3
-2
@@ -1829,17 +1829,18 @@ async function sendMessage() {
|
||||
const loadingBody = loadingMsg.querySelector(".msg-content");
|
||||
const loadingShell = loadingMsg.querySelector(".loading-shell");
|
||||
const loadingLabel = loadingMsg.querySelector(".loading-copy strong");
|
||||
loadingLabel.textContent = webSearch ? "Searching the web..." : "Generating response...";
|
||||
loadingLabel.textContent = "Preparing response...";
|
||||
try {
|
||||
let streamError = null;
|
||||
await stream("/chat/stream", {message, model: modelSelect.value, chatId: activeChatId, webSearch, reasoning}, {
|
||||
onStatus: ({status, sourceCount}) => {
|
||||
onStatus: ({status, sourceCount, searchState}) => {
|
||||
if (status === "accepted") {
|
||||
webSearchToggle.reset();
|
||||
reasoningToggle.reset();
|
||||
return;
|
||||
}
|
||||
loadingLabel.textContent = status === "searching" ? "Searching the web..."
|
||||
: searchState === "no_relevant_results" ? "No sufficiently relevant results. Generating response..."
|
||||
: sourceCount === 0 ? "No web results found. Generating response..." : "Generating response...";
|
||||
},
|
||||
onThinking: ({fullThinking}) => {
|
||||
|
||||
+6
-5
@@ -414,10 +414,11 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
||||
chatId: req.validatedBody.chatId,
|
||||
message: req.validatedBody.message,
|
||||
model: req.validatedBody.model,
|
||||
webSearch: req.validatedBody.webSearch
|
||||
webSearch: req.validatedBody.webSearch,
|
||||
reasoning: req.validatedBody.reasoning
|
||||
});
|
||||
if (result.error) return res.status(result.status).json({error: result.error});
|
||||
return res.json({reply: result.reply, ...(result.sources ? {sources: result.sources} : {})});
|
||||
if (result.error) return res.status(result.status).json({error: result.error, ...(result.searchState ? {searchState: result.searchState} : {})});
|
||||
return res.json({reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {})});
|
||||
} catch (error) {
|
||||
const mapped = modelService.mapError(error);
|
||||
return res.status(mapped.status).json(mapped.body);
|
||||
@@ -457,9 +458,9 @@ export function createChatsRouter({repositories, chatService, modelService, conf
|
||||
signal: generationController.signal
|
||||
});
|
||||
if (result.error) {
|
||||
sendEvent("error", {error: result.error});
|
||||
sendEvent("error", {error: result.error, ...(result.searchState ? {searchState: result.searchState} : {})});
|
||||
} else {
|
||||
sendEvent("done", {reply: result.reply, ...(result.sources ? {sources: result.sources} : {})});
|
||||
sendEvent("done", {reply: result.reply, searchState: result.searchState, ...(result.sources ? {sources: result.sources} : {})});
|
||||
}
|
||||
} catch (error) {
|
||||
const mapped = modelService.mapError(error);
|
||||
|
||||
+22
-26
@@ -18,7 +18,7 @@ import {
|
||||
retryStylePrompts
|
||||
} from "./chatMessageHelpers.js";
|
||||
|
||||
import {SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, selectSearchResults, MAX_CANDIDATES} from "./webSearchService.js";
|
||||
import {SearXNGSearchProvider, WebSearchError, buildWebSearchContext, retrieveWebSearch} from "./webSearchService.js";
|
||||
import {supportsReasoning} from "../../public/app/modelCapabilities.js";
|
||||
|
||||
export function createChatService(repositories, modelService, config, webSearchProvider = new SearXNGSearchProvider(config)) {
|
||||
@@ -267,12 +267,14 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
const personaStatePrompt = buildPersonaStatePrompt(repositories.getPersonaChatState(chatId, user));
|
||||
if (personaStatePrompt) messagesPayload.push({role: "system", content: personaStatePrompt});
|
||||
if (activePersona) messagesPayload.push({role: "system", content: buildRoleplayReplyGuardPrompt()});
|
||||
if (webSearchContext) messagesPayload.push(webSearchContext[0]);
|
||||
if (webSearchContext?.[1]) messagesPayload.push(webSearchContext[1]);
|
||||
messagesPayload.push(...conversation.map((entry) => ({
|
||||
const conversationMessages = conversation.map((entry) => ({
|
||||
role: entry.role === "bot" ? "assistant" : "user",
|
||||
content: entry.content
|
||||
})));
|
||||
}));
|
||||
messagesPayload.push(...conversationMessages.slice(0, -1));
|
||||
if (webSearchContext) messagesPayload.push(webSearchContext[0]);
|
||||
if (webSearchContext?.[1]) messagesPayload.push(webSearchContext[1]);
|
||||
if (conversationMessages.length) messagesPayload.push(conversationMessages.at(-1));
|
||||
return {session: activeSession, activePersona, activeUserPersona, messagesPayload};
|
||||
};
|
||||
|
||||
@@ -348,6 +350,7 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
const userMessageId = Number(inserted?.lastInsertRowid);
|
||||
try {
|
||||
let sources = null;
|
||||
let searchState = "skipped";
|
||||
let thinking = "";
|
||||
const captureThinking = (chunk, fullThinking) => {
|
||||
if (!useReasoning) return;
|
||||
@@ -359,36 +362,29 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
activeSignal.throwIfAborted();
|
||||
onStatus?.({status: "accepted"});
|
||||
if (webSearch) {
|
||||
const searchDecision = decideWebSearch(message);
|
||||
let searchQuery = null;
|
||||
let candidateCount = 0;
|
||||
let selectedCount = 0;
|
||||
if (searchDecision.needsSearch) {
|
||||
onStatus?.({status: "searching"});
|
||||
searchQuery = buildWebSearchQuery(message, searchDecision);
|
||||
const candidates = await webSearchProvider.search(searchQuery, {
|
||||
const search = await retrieveWebSearch(message, webSearchProvider, {
|
||||
signal: activeSignal,
|
||||
limit: MAX_CANDIDATES,
|
||||
...(searchDecision.intent === "news" ? {category: "news"} : {})
|
||||
onSearching: () => onStatus?.({status: "searching"})
|
||||
});
|
||||
candidateCount = candidates.length;
|
||||
sources = selectSearchResults(message, candidates, searchDecision);
|
||||
selectedCount = sources.length;
|
||||
}
|
||||
sources = search.sources;
|
||||
searchState = search.state;
|
||||
if (config.structuredRequestLoggingEnabled) {
|
||||
console.log(JSON.stringify({
|
||||
event: "web_search",
|
||||
webSearchRequested: true,
|
||||
searchNeeded: searchDecision.needsSearch,
|
||||
query: searchQuery,
|
||||
candidateCount,
|
||||
selectedCount,
|
||||
explicitSearchRequested: search.decision.explicitSearchIntent,
|
||||
automaticSearchDecision: search.decision.automaticSearchDecision,
|
||||
searchNeeded: search.decision.shouldSearch,
|
||||
searchState,
|
||||
queryCount: search.queries.length,
|
||||
candidateCount: search.candidates.length,
|
||||
selectedCount: sources?.length || 0,
|
||||
webContextSupplied: sources !== null,
|
||||
model: effectiveModel
|
||||
}));
|
||||
}
|
||||
}
|
||||
onStatus?.({status: "generating", ...(sources !== null ? {sourceCount: sources.length} : {})});
|
||||
onStatus?.({status: "generating", searchState, ...(sources !== null ? {sourceCount: sources.length} : {})});
|
||||
payload = buildConversationContext({user, chatId, session, message: null, sources});
|
||||
return payload.activePersona
|
||||
? generateValidatedRoleplayReply({
|
||||
@@ -420,11 +416,11 @@ export function createChatService(repositories, modelService, config, webSearchP
|
||||
repositories.completeChatTurn(chatId, userMessageId, fullReply, retryState, effectiveModel, sources, thinking);
|
||||
repositories.touchChat(chatId, user);
|
||||
scheduleContextSummaryUpdate({user, chatId, selectedModel: effectiveModel});
|
||||
return {reply: fullReply, userMessageId, ...(sources ? {sources} : {})};
|
||||
return {reply: fullReply, userMessageId, searchState, ...(sources ? {sources} : {})};
|
||||
} catch (error) {
|
||||
repositories.failChatTurn(chatId, userMessageId, String(error?.message || "Model request failed").slice(0, 500), webSearch ? userMessageId : effectiveParentId);
|
||||
repositories.touchChat(chatId, user);
|
||||
if (error instanceof WebSearchError) return {error: error.message, status: error.status};
|
||||
if (error instanceof WebSearchError) return {error: error.message, status: error.status, searchState: "failed"};
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/** @typedef {{search(query: string, options?: SearchOptions): Promise<SearchResult[]>}} WebSearchProvider */
|
||||
|
||||
export const MAX_QUERY_LENGTH = 500;
|
||||
export const MAX_CANDIDATES = 10;
|
||||
export const MAX_CANDIDATES = 15;
|
||||
export const MAX_SELECTED_SOURCES = 5;
|
||||
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
const STOP_WORDS = new Set(["a", "an", "and", "are", "did", "do", "for", "from", "happened", "in", "is", "it", "of", "on", "the", "to", "was", "what", "were", "with"]);
|
||||
@@ -37,41 +37,59 @@ function dateWords(isoDate) {
|
||||
return new Intl.DateTimeFormat("en-US", {month: "long", day: "numeric", year: "numeric", timeZone: "UTC"}).format(new Date(Date.UTC(year, month - 1, day)));
|
||||
}
|
||||
|
||||
function candidateDateMatches(text, isoDate) {
|
||||
if (!isoDate) return true;
|
||||
function containsRequestedDate(text, isoDate) {
|
||||
if (!isoDate) return false;
|
||||
const [year, month, day] = isoDate.split("-");
|
||||
const monthName = new Intl.DateTimeFormat("en-US", {month: "long", timeZone: "UTC"}).format(new Date(Date.UTC(Number(year), Number(month) - 1, 1)));
|
||||
const normalized = String(text || "").toLocaleLowerCase();
|
||||
const requested = new RegExp(`\\b(${isoDate}|${Number(day)}\\.${Number(month)}\\.${year}|${day}\\.${month}\\.${year}|${monthName.toLowerCase()}\\s+${Number(day)}(?:st|nd|rd|th)?[,]?\\s+${year})\\b`, "i");
|
||||
if (requested.test(normalized)) return true;
|
||||
const monthPattern = `(?:${monthName}|${monthName.slice(0, 3)}\\.?)`;
|
||||
const requested = new RegExp(`\\b(${isoDate}|${Number(day)}\\.${Number(month)}\\.${year}|${day}\\.${month}\\.${year}|${monthPattern}\\s+${Number(day)}(?:st|nd|rd|th)?[,]?\\s+${year}|${Number(day)}\\s+${monthPattern}\\s+${year})\\b`, "i");
|
||||
return requested.test(normalized);
|
||||
}
|
||||
|
||||
function candidateDateMatches(text, isoDate) {
|
||||
if (!isoDate || containsRequestedDate(text, isoDate)) return true;
|
||||
// A candidate that mentions another complete date is not evidence for an exact-date question.
|
||||
const otherDate = /\b\d{4}-\d{2}-\d{2}\b|\b\d{1,2}\.\d{1,2}\.\d{4}\b|\b(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2}(?:st|nd|rd|th)?[,]?\s+\d{4}\b/i;
|
||||
return !otherDate.test(normalized);
|
||||
const month = "(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)";
|
||||
const otherDate = new RegExp(`\\b\\d{4}-\\d{2}-\\d{2}\\b|\\b\\d{1,2}\\.\\d{1,2}\\.\\d{4}\\b|\\b${month}\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?[,]?\\s+\\d{4}\\b|\\b\\d{1,2}\\s+${month}\\.?\\s+\\d{4}\\b`, "i");
|
||||
return !otherDate.test(String(text || ""));
|
||||
}
|
||||
|
||||
function meaningfulTokens(message) {
|
||||
return normalizeTitle(message).split(" ").filter((token) => token.length > 2 && !STOP_WORDS.has(token));
|
||||
}
|
||||
|
||||
export function decideWebSearch(message) {
|
||||
export function hasExplicitSearchIntent(message) {
|
||||
return /\b(?:search\s+(?:(?:the\s+)?(?:internet|web)|online|for\b)|look\s+(?:(?:this|it|that)\s+)?up(?:\s+online)?|check\s+(?:online|the\s+(?:web|internet))|use\s+the\s+(?:internet|web)|find\s+(?:sources|(?:the\s+)?latest\s+information|current\s+information)|google\s+(?:this|that|it)|online\s+sources?|web\s+sources?)\b/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
function searchSubject(message) {
|
||||
return String(message || "").replace(/\s+/g, " ").trim()
|
||||
.replace(/^(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:please\s+)?(?:search\s+(?:(?:the\s+)?(?:internet|web)|online)(?:\s+for)?|search\s+for|look\s+(?:(?:this|it|that)\s+)?up(?:\s+online)?|check\s+online(?:\s+for)?|use\s+the\s+(?:internet|web)(?:\s+to\s+find)?|find\s+sources(?:\s+for)?|google\s+(?:this|that|it))\s*[:,-]?\s*/i, "")
|
||||
.replace(/\baverage lifespan\b/gi, "average life expectancy").trim();
|
||||
}
|
||||
|
||||
export function decideWebSearch(message, webSearchEnabled = true) {
|
||||
const text = String(message || "").trim();
|
||||
const lower = text.toLowerCase();
|
||||
const explicitDate = getExplicitDate(text);
|
||||
if (/^(?:what(?:'s| is) )?(?:the )?(?:date|day)(?: is it)?(?: today)?\??$/i.test(text) || /\bwhat date is it today\b/i.test(text) || /\bwhat time is it\b/i.test(text)) {
|
||||
return {needsSearch: false, intent: "runtime"};
|
||||
}
|
||||
if (/^\s*[-+*/().\d\s]+\s*=*\s*$/u.test(text) || /^(?:rewrite|rephrase|summarize|translate|explain)\b/i.test(text)) {
|
||||
return {needsSearch: false, intent: "local"};
|
||||
}
|
||||
const asksForSources = /\b(?:search (?:the )?web|online sources?|web sources?|look (?:it )?up)\b/i.test(text);
|
||||
const explicitSearchIntent = hasExplicitSearchIntent(text);
|
||||
const runtime = /^(?:what(?:'s| is) )?(?:the )?(?:date|day)(?: is it)?(?: today)?\??$/i.test(text) || /\bwhat date is it today\b/i.test(text) || /\bwhat time is it\b/i.test(text);
|
||||
const local = /^(?:(?:what is|calculate)\s+)?[-+*/().\d\s]+[=?]*\??$/i.test(text) || /^(?:rewrite|rephrase|summarize|translate|explain)\b/i.test(text);
|
||||
const newsIntent = /\b(?:news|latest|current|recent|today's|yesterday|last week|last month|breaking|what happened)\b/i.test(lower) || Boolean(explicitDate && /\b(?:happened|events?|news)\b/i.test(text));
|
||||
const needsSearch = asksForSources || newsIntent || Boolean(explicitDate && /\b(?:happened|events?|news)\b/i.test(text)) || /\b(?:price|release|version|ceo|stock)\b/i.test(lower);
|
||||
return {needsSearch, intent: newsIntent ? "news" : asksForSources ? "research" : "general", explicitDate};
|
||||
const automaticSearchDecision = !runtime && !local && (newsIntent || /\b(?:price|release|version|ceo|stock)\b/i.test(lower));
|
||||
const needsSearch = explicitSearchIntent || automaticSearchDecision;
|
||||
return {
|
||||
webSearchEnabled: webSearchEnabled === true, explicitSearchIntent, automaticSearchDecision,
|
||||
needsSearch, shouldSearch: webSearchEnabled === true && needsSearch,
|
||||
intent: newsIntent ? "news" : explicitSearchIntent ? "research" : runtime ? "runtime" : local ? "local" : "general",
|
||||
explicitDate
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the user's literal wording while adding an unambiguous day-first ISO date for search engines. */
|
||||
/** Remove search-command wording without dropping dates or changing the requested year. */
|
||||
export function buildWebSearchQuery(message, decision = decideWebSearch(message)) {
|
||||
const query = String(message || "").replace(/\s+/g, " ").trim().slice(0, MAX_QUERY_LENGTH);
|
||||
const query = (searchSubject(message) || String(message || "").trim()).slice(0, MAX_QUERY_LENGTH);
|
||||
if (!query) return query;
|
||||
if (decision.intent === "news" && decision.explicitDate && /\b(?:happened|events?|news)\b/i.test(query)) {
|
||||
return `major news events ${dateWords(decision.explicitDate)} ${decision.explicitDate}`.slice(0, MAX_QUERY_LENGTH);
|
||||
@@ -79,6 +97,46 @@ export function buildWebSearchQuery(message, decision = decideWebSearch(message)
|
||||
return decision.explicitDate ? `${query} ${decision.explicitDate}`.slice(0, MAX_QUERY_LENGTH) : query;
|
||||
}
|
||||
|
||||
export function buildWebSearchQueries(message, decision = decideWebSearch(message)) {
|
||||
const first = buildWebSearchQuery(message, decision);
|
||||
if (decision.explicitDate && decision.intent === "news" && /\b(?:what happened|events?|news)\b/i.test(message)) {
|
||||
return [first, `${dateWords(decision.explicitDate)} major world news`];
|
||||
}
|
||||
return [first];
|
||||
}
|
||||
|
||||
/** Retrieve before generation; only try a second date query when the first has no usable evidence. */
|
||||
export async function retrieveWebSearch(message, provider, {signal, onSearching = () => {}} = {}) {
|
||||
const decision = decideWebSearch(message);
|
||||
if (!decision.shouldSearch) return {decision, state: "skipped", queries: [], candidates: [], sources: null};
|
||||
onSearching();
|
||||
const queries = [];
|
||||
let candidates = [];
|
||||
let sources = [];
|
||||
for (const query of buildWebSearchQueries(message, decision)) {
|
||||
queries.push(query);
|
||||
try {
|
||||
const results = await provider.search(query, {
|
||||
signal, limit: MAX_CANDIDATES,
|
||||
// Historic events often live in general web indexes, not live news indexes.
|
||||
...(decision.intent === "news" && !decision.explicitDate ? {category: "news"} : {})
|
||||
});
|
||||
// Prefer fresh fallback evidence over earlier weak duplicates.
|
||||
const merged = [...results, ...candidates].sort((a, b) =>
|
||||
Number(Boolean(b?.snippet || b?.content)) - Number(Boolean(a?.snippet || a?.content)));
|
||||
candidates = normalizeSearchCandidates(merged, MAX_CANDIDATES * 2);
|
||||
sources = selectSearchResults(message, candidates, decision);
|
||||
if (sources.length) break;
|
||||
} catch (error) {
|
||||
if (signal?.aborted || queries.length === 1) throw error;
|
||||
// A failed optional fallback must not erase a successful first search.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {decision, queries, candidates, sources,
|
||||
state: sources.length ? "succeeded" : candidates.length ? "no_relevant_results" : "no_results"};
|
||||
}
|
||||
|
||||
export class WebSearchError extends Error {
|
||||
constructor() {
|
||||
super("Web search is currently unavailable.");
|
||||
@@ -100,7 +158,8 @@ export function normalizeSearchCandidates(value, limit = MAX_CANDIDATES) {
|
||||
if (typeof item.title !== "string" || !item.title.trim()) continue;
|
||||
const title = item.title.trim().slice(0, 300);
|
||||
const titleKey = normalizeTitle(title);
|
||||
const snippet = typeof item.snippet === "string" ? item.snippet.trim().slice(0, 1500) : "";
|
||||
const rawSnippet = typeof item.snippet === "string" && item.snippet.trim() ? item.snippet : item.content;
|
||||
const snippet = typeof rawSnippet === "string" ? rawSnippet.trim().slice(0, 1500) : "";
|
||||
if (!titleKey || (!snippet && titleKey.length < 8) || seenUrls.has(url.href) || seenTitles.has(titleKey)) continue;
|
||||
sources.push({
|
||||
title,
|
||||
@@ -125,20 +184,27 @@ export function normalizeSources(value) {
|
||||
|
||||
/** Select relevant evidence only. Search snippets remain untrusted data throughout. */
|
||||
export function selectSearchResults(question, candidates, decision = decideWebSearch(question)) {
|
||||
const tokens = meaningfulTokens(question);
|
||||
const tokens = meaningfulTokens(searchSubject(question));
|
||||
const wantsCommunity = /\b(?:reddit|community|communities|social media|people saying|reactions?)\b/i.test(question);
|
||||
const wantsHolidays = /\b(?:holiday|holidays)\b/i.test(question);
|
||||
const lowPriorityDomains = /(?:reddit\.com|facebook\.com|instagram\.com|tiktok\.com|pinterest\.com|checkiday\.com)/i;
|
||||
const selected = [];
|
||||
const domainCounts = new Map();
|
||||
for (const candidate of normalizeSearchCandidates(candidates)) {
|
||||
for (const candidate of normalizeSearchCandidates(candidates, MAX_CANDIDATES * 2)) {
|
||||
// Titles remain valid in old stored sources, but new selections need textual evidence.
|
||||
if (candidate.snippet.length < 16 || meaningfulTokens(candidate.snippet).length < 2) continue;
|
||||
if (/^(?:read|browse|explore|discover)\b.*\b(?:latest headlines|top stories|on this day)\b/i.test(candidate.snippet)) continue;
|
||||
if (/^(?:(?:an?|only)\s+)?incidental mention\b/i.test(candidate.snippet)) continue;
|
||||
const evidence = `${candidate.title} ${candidate.snippet}`;
|
||||
if (!candidateDateMatches(evidence, decision.explicitDate)) continue;
|
||||
if (decision.explicitDate && decision.intent === "news" && !/\b(?:news|events?|report|official|statement|government|world)\b/i.test(evidence)) continue;
|
||||
const datedEvidence = `${evidence} ${candidate.publishedDate || ""} ${candidate.url.replaceAll("/", "-")}`;
|
||||
const exactDateEvidence = decision.explicitDate && containsRequestedDate(datedEvidence, decision.explicitDate);
|
||||
if (decision.explicitDate && decision.intent === "news" && !exactDateEvidence) continue;
|
||||
if (decision.explicitDate && /\b(?:born on|birthdays?|holidays?)\b/i.test(evidence) && !wantsHolidays) continue;
|
||||
const haystack = normalizeTitle(evidence);
|
||||
const matches = tokens.filter((token) => haystack.includes(token)).length;
|
||||
let relevance = matches * 3;
|
||||
if (decision.explicitDate && candidateDateMatches(evidence, decision.explicitDate)) relevance += 5;
|
||||
if (exactDateEvidence) relevance += 5;
|
||||
if (decision.intent === "news" && /\b(?:news|report|events?|world|government|official|statement)\b/i.test(evidence)) relevance += 2;
|
||||
const domain = new URL(candidate.url).hostname.replace(/^www\./, "");
|
||||
if (lowPriorityDomains.test(domain) && !(wantsCommunity || (wantsHolidays && /checkiday/i.test(domain)))) relevance -= 3;
|
||||
@@ -147,13 +213,16 @@ export function selectSearchResults(question, candidates, decision = decideWebSe
|
||||
// keyword is present. General queries need at least one lexical signal.
|
||||
const threshold = decision.explicitDate && decision.intent === "news" ? 5 : 3;
|
||||
if (relevance < threshold) continue;
|
||||
const sameDomain = domainCounts.get(domain) || 0;
|
||||
if (sameDomain >= 2) continue;
|
||||
selected.push({...candidate, relevance});
|
||||
domainCounts.set(domain, sameDomain + 1);
|
||||
}
|
||||
return selected
|
||||
.sort((a, b) => b.relevance - a.relevance)
|
||||
.filter((candidate) => {
|
||||
const domain = new URL(candidate.url).hostname.replace(/^www\./, "");
|
||||
const count = domainCounts.get(domain) || 0;
|
||||
domainCounts.set(domain, count + 1);
|
||||
return count < 2;
|
||||
})
|
||||
.slice(0, MAX_SELECTED_SOURCES)
|
||||
.map(({title, url, snippet}) => ({title, url, snippet}));
|
||||
}
|
||||
@@ -205,7 +274,7 @@ export class SearXNGSearchProvider {
|
||||
}
|
||||
const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!data || !Array.isArray(data.results)) throw new WebSearchError();
|
||||
const sources = normalizeSearchCandidates(data.results.map((item) => ({...item, snippet: item?.content})), Math.max(1, Math.min(MAX_CANDIDATES, Number.isInteger(limit) ? limit : MAX_CANDIDATES)));
|
||||
const sources = normalizeSearchCandidates(data.results, Math.max(1, Math.min(MAX_CANDIDATES, Number.isInteger(limit) ? limit : MAX_CANDIDATES)));
|
||||
// An actual empty set is valid; a wholly malformed set is not.
|
||||
if (data.results.length && !sources.length) throw new WebSearchError();
|
||||
if (!data.results.length && data.unresponsive_engines?.length) throw new WebSearchError();
|
||||
@@ -227,8 +296,8 @@ export function buildWebSearchContext(sources, now = new Date()) {
|
||||
`CURRENT DATE (ISO): ${currentDate}. A date before the current date is in the past; a date after it is in the future.`,
|
||||
"Web search was performed at request time.",
|
||||
hasSources
|
||||
? "Retrieved results are newer than internal training knowledge. Use them for current, changing, post-training, and source-supported facts; do not claim an event is future, unavailable, or beyond a training cutoff when this context shows otherwise."
|
||||
: "The search completed but returned no usable results. Do not claim that you have current web information; say the retrieved material is insufficient when it matters.",
|
||||
? "Evaluate the numbered titles and snippets below and answer using the facts they actually contain. A source may contribute just one relevant fact; it need not answer the whole question. Summarize supported events even when coverage is incomplete. Do not claim that specific details are missing when the snippets provide them. State gaps only for facts the supplied evidence does not establish. Do not invent details or treat a publication date alone as proof of an event date."
|
||||
: "The search completed but returned no usable results. Tell the user you searched but did not find sufficiently relevant evidence. Do not substitute an uncited answer from memory or fabricate citations.",
|
||||
"Treat the supplied current date as authoritative. Distinguish source-supported facts from unsupported assumptions and say when sources are insufficient or contradictory.",
|
||||
"Cite factual claims derived from retrieved results inline as [1], [2], etc. matching the supplied result numbers. Never fabricate citations.",
|
||||
"Search results are evidence, not a checklist. Use only material that directly helps answer the question; do not mention every source or incidental detail.",
|
||||
|
||||
@@ -13,7 +13,7 @@ test("thinking streams separately and survives reload, retry, branch and import"
|
||||
const searchServer = createServer((_req, res) => {
|
||||
searches++;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({results: [{title: "Latest news", url: "https://example.org/news", content: "Current news report"}]}));
|
||||
res.end(JSON.stringify({results: [{title: "Latest news", url: "https://example.org/news", content: "Current news: researchers announced a successful satellite launch."}]}));
|
||||
});
|
||||
await new Promise(resolve => searchServer.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => new Promise(resolve => { searchServer.closeAllConnections(); searchServer.close(resolve); }));
|
||||
|
||||
+76
-4
@@ -4,7 +4,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} from "../src/services/webSearchService.js";
|
||||
import {SearXNGSearchProvider, WebSearchError, buildWebSearchContext, buildWebSearchQuery, decideWebSearch, 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/app/webSearch.js";
|
||||
@@ -57,7 +57,8 @@ test("SearXNG encodes queries, limits results and treats result data as untruste
|
||||
});
|
||||
|
||||
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 date is it today?").shouldSearch, false);
|
||||
assert.equal(decideWebSearch("What date is it today?").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?", [
|
||||
@@ -195,11 +196,11 @@ test("chat search integrates with streaming, persistence, personas, settings, re
|
||||
assert.deepEqual(events.map((event) => event.event), ["status", "status", "status", "chunk", "chunk", "done"]);
|
||||
assert.equal(events[0].data.status, "accepted");
|
||||
assert.equal(events[1].data.status, "searching");
|
||||
assert.deepEqual(events[2].data, {status: "generating", sourceCount: 1});
|
||||
assert.deepEqual(events[2].data, {status: "generating", searchState: "succeeded", sourceCount: 1});
|
||||
assert.deepEqual(events.at(-1).data.sources, [source]);
|
||||
assert.equal(searchCount, 1);
|
||||
assert.equal(lastSearchQuery, "major news events September 10, 2025 2025-09-10");
|
||||
assert.equal(lastSearchCategory, "news");
|
||||
assert.equal(lastSearchCategory, null);
|
||||
const call = calls.at(-1);
|
||||
assert.equal(call.model, "chosen:model");
|
||||
assert.deepEqual(call.options.generation, {temperature: 0.4, contextLength: 4096, responseLength: 300});
|
||||
@@ -286,6 +287,77 @@ test("chat search integrates with streaming, persistence, personas, settings, re
|
||||
assert.ok(chatService.listActiveBranchMessages(localChat.id).length >= 4);
|
||||
});
|
||||
|
||||
test("explicit search intent overrides smart skipping and preserves the requested year", () => {
|
||||
for (const question of [
|
||||
"Search the internet for the average lifespan in 2026",
|
||||
"Search the web for what is 2 + 2",
|
||||
"Look this up online", "Find sources for life expectancy",
|
||||
"Check online for life expectancy", "Use the internet for this",
|
||||
"Find the latest information on life expectancy", "Can you search for life expectancy?",
|
||||
"Google this", "Find current information on life expectancy",
|
||||
"Search the internet for what date it is today",
|
||||
"Explain recursion and look it up online"
|
||||
]) {
|
||||
const decision = decideWebSearch(question);
|
||||
assert.equal(decision.explicitSearchIntent, true, question);
|
||||
assert.equal(decision.shouldSearch, true, question);
|
||||
assert.equal(decideWebSearch(question, false).shouldSearch, false);
|
||||
}
|
||||
for (const question of ["What is 2 + 2?", "What date is it today?", "Rewrite this sentence", "Explain recursion"]) {
|
||||
const decision = decideWebSearch(question);
|
||||
assert.equal(decision.explicitSearchIntent, false);
|
||||
assert.equal(decision.automaticSearchDecision, false);
|
||||
assert.equal(decision.shouldSearch, false);
|
||||
}
|
||||
assert.equal(buildWebSearchQuery("Search the internet for the average lifespan in 2026"), "the average life expectancy in 2026");
|
||||
});
|
||||
|
||||
test("date evidence admits individual events, rejects empty/wrong dates, and bounds fallback searches", async () => {
|
||||
const question = "what happened on 10.09.2025?";
|
||||
const useful = [
|
||||
{title: "Satellite launched", url: "https://space.example/launch", content: "On September 10, 2025, researchers launched a satellite into orbit."},
|
||||
{title: "Agreement signed", url: "https://diplomacy.example/deal", content: "Officials signed a cooperation agreement on 10 September 2025."}
|
||||
];
|
||||
const weak = [
|
||||
{title: "September 10, 2025 major world news", url: "https://empty.example", content: ""},
|
||||
{title: "October 9, 2025 world news", url: "https://wrong.example", content: "On October 9, 2025, officials signed an unrelated agreement."},
|
||||
{title: "On This Day", url: "https://calendar.example", content: "Browse the latest headlines and on this day pages for September 10, 2025."}
|
||||
];
|
||||
assert.deepEqual(selectSearchResults(question, weak), []);
|
||||
const selected = selectSearchResults(question, [...weak, ...useful]);
|
||||
assert.equal(selected.length, 2);
|
||||
assert.deepEqual(selected.map(source => source.snippet), useful.map(item => item.content));
|
||||
const queries = [];
|
||||
const search = await retrieveWebSearch(question, {async search(query, options) {
|
||||
queries.push(query);
|
||||
assert.equal(options.category, undefined);
|
||||
return queries.length === 1 ? weak : [...useful, useful[0]];
|
||||
}});
|
||||
assert.equal(search.state, "succeeded");
|
||||
assert.equal(queries.length, 2);
|
||||
assert.ok(queries.every(query => query.includes("2025")));
|
||||
assert.deepEqual(search.sources, selected);
|
||||
let duplicateCalls = 0;
|
||||
const refreshed = await retrieveWebSearch(question, {async search() {
|
||||
duplicateCalls++;
|
||||
return duplicateCalls === 1
|
||||
? [{...useful[0], content: "Browse the latest headlines and on this day pages."}]
|
||||
: [useful[0]];
|
||||
}});
|
||||
assert.equal(refreshed.sources[0].snippet, useful[0].content);
|
||||
let calls = 0;
|
||||
await retrieveWebSearch(question, {async search() { calls++; return useful; }});
|
||||
assert.equal(calls, 1);
|
||||
for (const [results, state] of [[[], "no_results"], [weak, "no_relevant_results"]]) {
|
||||
const outcome = await retrieveWebSearch(question, {async search() { return results; }});
|
||||
assert.equal(outcome.state, state);
|
||||
assert.equal(outcome.queries.length, 2);
|
||||
}
|
||||
const skipped = await retrieveWebSearch("What is 2 + 2?", {search() { assert.fail("Must not search"); }});
|
||||
assert.equal(skipped.state, "skipped");
|
||||
assert.equal(skipped.sources, null);
|
||||
});
|
||||
|
||||
class Element {
|
||||
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
|
||||
Reference in New Issue
Block a user