improve reasoning toggle tests and refactor model capability checks

This commit is contained in:
2026-09-21 21:46:22 +02:00
parent 6cb0cbcee0
commit a8e348e0c7
8 changed files with 217 additions and 75 deletions
+99 -30
View File
@@ -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.",