v4.1.15: 뉴스/검색 신뢰성 개선 — AUTO-RECOVER 무한재시도·해외뉴스 국가필터 버그 수정

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).
This commit is contained in:
kim
2026-07-17 00:11:51 +09:00
parent 636a06f17d
commit 449ecaea9d
3 changed files with 42 additions and 12 deletions
+1 -1
View File
@@ -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++;
+19 -7
View File
@@ -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);
+22 -4
View File
@@ -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<ToolResult> {
async function searchSearXNG(
query: string,
limit: number,
baseUrl: string,
opts?: { category?: string; timeRange?: 'day' | 'week' | 'month' | 'year' },
): Promise<ToolResult> {
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<ToolResult> => {
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);