diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index a6cdb6c..da38b1a 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -7446,7 +7446,7 @@ RULES: ? /^browser_/i : desktopAutomationRequest ? /^desktop_/i - : /^(web_search|web_fetch|weather_|ollama_web_)/i; + : /^(web_search|web_fetch|weather_|ollama_web_|news_search)/i; const hasGroundingToolCall = allToolResults.some((r) => groundingToolPattern.test(String(r?.name || ''))); if (((queryNeedsTools && (looksLikeReasoning || looksLikeRefusal)) || liveDataRequest) && !hasGroundingToolCall) { toolSkipForcedRetries++; diff --git a/src/tools/news.ts b/src/tools/news.ts index f4d2e59..6a0530f 100644 --- a/src/tools/news.ts +++ b/src/tools/news.ts @@ -43,16 +43,16 @@ export const newsSearchTool = { description: 'Get real news headlines from the last 48 hours via NewsData.io — use this instead of web_search for any "오늘 뉴스"/"today\'s news"/breaking news request. Returns real articles with actual publish dates and sources, not generic web pages.', schema: { query: 'Optional keyword/topic to filter by (e.g. "economy", "South America"). Omit for general top headlines.', - country: 'Optional ISO 3166-1 alpha-2 country code (e.g. "kr" for Korea, "us" for USA, "br" for Brazil). Omit for worldwide.', - language: 'Optional ISO 639-1 language code (e.g. "ko", "en"). Defaults to "en" if country isn\'t Korea-related, "ko" otherwise — pass explicitly to override.', - category: 'Optional: business, entertainment, environment, food, health, politics, science, sports, technology, top, tourism, or world.', + country: 'Optional ISO 3166-1 alpha-2 country code (e.g. "kr" for Korea, "us" for USA, "br" for Brazil). Accepts a comma-separated list for multiple countries in one call, MAX 5 (e.g. "de,fr,gb,it,es" — verified working, each result tagged with its source country; a 6th+ country errors with "Number of country cannot exceeded 5"). There is NO code for continents/regions ("eu"/"europe" will error with "country does not exist") — for a "European news" request, pass up to 5 major European country codes instead (e.g. "de,fr,gb,it,es"). Omit country entirely for worldwide.', + language: 'Optional ISO 639-1 language code (e.g. "ko", "en"). IGNORED whenever country is also set — NewsData.io silently drops the country filter and returns irrelevant results when language doesn\'t match the country\'s dominant language (e.g. country="de"+language="en" returns US news, not German). Only pass language when country is omitted.', + category: 'Optional: business, entertainment, environment, food, health, politics, science, sports, technology, top, tourism, or world. "world" is IGNORED when country is also set (NewsData.io silently drops the country filter for that combo) — for a specific country\'s news, omit category or use "top"/"politics"/etc. instead.', }, jsonSchema: { type: 'object', properties: { query: { type: 'string', description: 'Optional keyword/topic filter. Omit for general top headlines.' }, - country: { type: 'string', description: 'Optional ISO 3166-1 alpha-2 country code, e.g. "kr", "us", "br", "jp".' }, - language: { type: 'string', description: 'Optional ISO 639-1 language code, e.g. "ko", "en".' }, + country: { type: 'string', description: 'Optional ISO 3166-1 alpha-2 country code, e.g. "kr", "us", "br", "jp". Comma-separated list for multiple countries, MAX 5 (e.g. "de,fr,gb,it,es"). No continent code exists — for "European news" pass up to 5 country codes (e.g. "de,fr,gb,it,es"), not "eu".' }, + language: { type: 'string', description: 'Optional ISO 639-1 language code, e.g. "ko", "en". Ignored when country is set (see country field) — only pass one or the other.' }, category: { type: 'string', description: '"business"|"entertainment"|"environment"|"food"|"health"|"politics"|"science"|"sports"|"technology"|"top"|"tourism"|"world"' }, }, additionalProperties: false, @@ -63,8 +63,20 @@ export const newsSearchTool = { const query = String(args?.query || '').trim(); const country = String(args?.country || '').trim().toLowerCase(); - const language = String(args?.language || '').trim().toLowerCase(); - const category = String(args?.category || '').trim().toLowerCase(); + // NewsData.io silently drops the country filter (falling back to a generic English-language + // world-news pool instead of erroring) in two independently-confirmed cases — verified + // 2026-07-16 against the live API: + // 1. language doesn't match the country's dominant language (country=de+language=en + // returned US Congress/FDA-recall articles, not German news; country=de alone or + // country=de+language=de both returned real German news) + // 2. category is "world" (country=de+category=world broke the same way; country=de with + // any other category like "politics"/"top" returned real German news). "world" scoped + // to a single country is a contradictory combination anyway. + // Dropping both whenever country is set is a strict improvement: a no-op when they'd have + // been compatible, and prevents the silent-wrong-country fallback when they aren't. + const language = country ? '' : String(args?.language || '').trim().toLowerCase(); + let category = String(args?.category || '').trim().toLowerCase(); + if (country && category === 'world') category = ''; const params = new URLSearchParams({ apikey: apiKey }); if (query) params.set('q', query); diff --git a/src/tools/web.ts b/src/tools/web.ts index a9eec3e..b536241 100644 --- a/src/tools/web.ts +++ b/src/tools/web.ts @@ -586,16 +586,34 @@ async function searchBrave(query: string, limit: number, apiKey: string): Promis }; } +// Hangul-presence is a cheap, reliable enough signal for which language SearXNG's engines +// should be told to prefer — matches the existing bilingual query-crafting convention +// (Korean text for domestic queries, English for international) documented alongside +// isNewsSeekingQuery, just expressed as an explicit API param instead of only query wording. +function detectQueryLanguage(query: string): 'ko' | 'en' { + return /[가-힣]/.test(query) ? 'ko' : 'en'; +} + // ── SearXNG (self-hosted/public metasearch, no key) ────────────────────────── -async function searchSearXNG(query: string, limit: number, baseUrl: string, category?: string): Promise { +async function searchSearXNG( + query: string, + limit: number, + baseUrl: string, + opts?: { category?: string; timeRange?: 'day' | 'week' | 'month' | 'year' }, +): Promise { const base = baseUrl.replace(/\/+$/, ''); // Default (no categories param) hits SearXNG's "general" category, which includes engines // like Wikipedia — fine for most queries, but Wikipedia's static reference pages (e.g. a // "2026" year-overview article) are not news and shouldn't compete with actual dated // articles for a news-seeking query. Restricting to categories=news routes to the engines // actually tagged "news" (daum news, yahoo news, presearch's news variant) instead. - const categoryParam = category ? `&categories=${encodeURIComponent(category)}` : ''; - const url = `${base}/search?q=${encodeURIComponent(query)}&format=json${categoryParam}`; + const categoryParam = opts?.category ? `&categories=${encodeURIComponent(opts.category)}` : ''; + // time_range narrows results to engines' own recency metadata — a stronger filter than + // categories=news alone against stale-but-still-"news-tagged" pages. 'week' rather than + // 'day' to leave margin for engines with indexing lag instead of returning nothing. + const timeRangeParam = opts?.timeRange ? `&time_range=${encodeURIComponent(opts.timeRange)}` : ''; + const languageParam = `&language=${encodeURIComponent(detectQueryLanguage(query))}`; + const url = `${base}/search?q=${encodeURIComponent(query)}&format=json${categoryParam}${timeRangeParam}${languageParam}`; const res = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': 'SmallClaw/1.0' }, signal: AbortSignal.timeout(15_000), @@ -848,7 +866,7 @@ export async function executeWebSearch(args: { query: string; max_results?: numb const runProvider = (provider: SearchProvider): Promise => { switch (provider) { case 'tavily': return searchTavily(args.query, limit, cfg.tavilyKey as string); - case 'searxng': return searchSearXNG(args.query, limit, cfg.searxngUrl as string, isNewsSeekingQuery(args.query) ? 'news' : undefined); + case 'searxng': return searchSearXNG(args.query, limit, cfg.searxngUrl as string, isNewsSeekingQuery(args.query) ? { category: 'news', timeRange: 'week' } : undefined); case 'google': return searchGoogle(args.query, limit, cfg.googleKey as string, cfg.googleCx as string); case 'brave': return searchBrave(args.query, limit, cfg.braveKey as string); case 'ollama_cloud': return searchOllamaCloud(args.query, limit, cfg.ollamaApiKey as string);