From 449ecaea9dc4da7fa5ab56f0561025d15be45a39 Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 17 Jul 2026 00:11:51 +0900 Subject: [PATCH] =?UTF-8?q?v4.1.15:=20=EB=89=B4=EC=8A=A4/=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=8B=A0=EB=A2=B0=EC=84=B1=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=E2=80=94=20AUTO-RECOVER=20=EB=AC=B4=ED=95=9C=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=C2=B7=ED=95=B4=EC=99=B8=EB=89=B4=EC=8A=A4=20=EA=B5=AD?= =?UTF-8?q?=EA=B0=80=ED=95=84=ED=84=B0=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AUTO-RECOVER의 근거도구 인식 정규식에 news_search가 빠져있어서, 뉴스 검색을 몇 번을 성공시켜도 "아직 도구 안 불렀다"고 오판해 강제 재호출을 반복 주입하던 버그 수정(server-v2.ts) — 실측상 "오늘 유럽 뉴스" 요청 하나에 13라운드 넘게 반복 호출되던 원인이었음, 모델 종류와 무관하게 재현됨. news_search(NewsData.io)는 country+language 불일치, country+category=world 조합에서 country 필터를 조용히 무시하고 엉뚱한 나라 뉴스를 반환함을 확인 — country 지정 시 language/category=world를 드롭하도록 수정하고, "유럽" 같은 대륙 코드가 없다는 점 + country는 콤마로 최대 5개까지 받는다는 점을 도구 설명에 명시(news.ts). SearXNG 쿼리에 language/time_range 파라미터 추가로 오래된 결과 필터링 정교화(web.ts). --- src/gateway/server-v2.ts | 2 +- src/tools/news.ts | 26 +++++++++++++++++++------- src/tools/web.ts | 26 ++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 12 deletions(-) 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);