v4.1.11: 뉴스 전용 도구 신설(NewsData.io) + 검색 루프/설정 버그 수정
- news_search 도구 신설(NewsData.io /latest API) — 과거 48시간 실시간 뉴스, 국가/언어/카테고리 필터, 실제 pubDate·출처 포함. 뉴스 요청엔 web_search보다 우선 사용하도록 시스템프롬프트·강제재시도 넛지 전부 갱신 — 위키피디아 연도페이지·날짜 오라벨링 문제가 구조적으로 해결됨 - 검색예산(5회) 차단 후에도 모델이 무시하고 계속 재시도하는 문제 수정 — "오늘 남미 주요 뉴스" 요청에서 실제 검색 6회 후 42라운드가 전부 헛되이 낭비된 사례 확인(TOOL[48]까지 감). 두 번째 차단부터는 해당 턴 나머지 동안 모델에게 tools를 빈 배열로 보내 물리적으로 더 이상 호출 못 하게 함 - OpenWeather 도시명 모호성 버그 수정 — "Rome, Italy"가 미국 조지아주 Rome으로 잘못 resolve되던 문제. 국가명→ISO코드 자동 정규화(40여개국) + 국가 불일치 시 에러로 재시도 유도 - SearXNG general 카테고리에 뉴스 아닌 위키피디아 엔진이 섞여있던 문제 수정 — 뉴스 쿼리는 categories=news로 daum_news/yahoo_news 등 뉴스 전용 엔진만 사용하도록 변경(사후 필터링 아닌 근본 차단) - 설정 화면에서 모델 전환 시 models.fallback/profiles가 매번 사라지던 버그 수정 — /api/settings/model 저장 로직이 models 객체를 통째로 새로 만들어서 덮어쓰던 것을, 기존 값을 먼저 펼친 뒤 필요한 필드만 덮어쓰도록 변경 - news.newsdata_api_key를 SECRET_FIELD_MAP에 추가해 vault 자동 암호화 대상에 포함(평문 미저장) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { getVault } from '../security/vault.js';
|
||||
|
||||
const NEWSDATA_BASE = 'https://newsdata.io/api/1/latest';
|
||||
|
||||
function getApiKey(): string | undefined {
|
||||
try {
|
||||
const cm = getConfig();
|
||||
const cfg = cm.getConfig();
|
||||
const resolved = cm.resolveSecret((cfg as any).news?.newsdata_api_key);
|
||||
if (resolved) return resolved;
|
||||
const vault = getVault(cm.getConfigDir());
|
||||
return vault.get('news.newsdata_api_key', 'news:getkey')?.expose();
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatArticles(results: any[]): string {
|
||||
if (!results.length) return '(no articles found)';
|
||||
return results.slice(0, 10).map((a, i) => {
|
||||
const title = a.title || '(no title)';
|
||||
const source = a.source_name || a.source_id || '';
|
||||
const pubDate = a.pubDate || '';
|
||||
const link = a.link || '';
|
||||
const desc = (a.description || '').slice(0, 200);
|
||||
return [
|
||||
`[${i + 1}] ${title}`,
|
||||
` Published: ${pubDate} | Source: ${source}`,
|
||||
` ${link}`,
|
||||
desc ? ` ${desc}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
// NewsData.io's /latest endpoint only covers the past 48 hours (free tier) — exactly the
|
||||
// "today's news" window this tool exists for. Unlike web_search's generic scrape-and-rank,
|
||||
// every result here carries a real pubDate and source from the provider's own metadata, so
|
||||
// there's no risk of the "labeled as today but actually 3 days old" or "Wikipedia year page
|
||||
// instead of a headline" failure modes web_search hit for news queries.
|
||||
export const newsSearchTool = {
|
||||
name: 'news_search',
|
||||
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.',
|
||||
},
|
||||
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".' },
|
||||
category: { type: 'string', description: '"business"|"entertainment"|"environment"|"food"|"health"|"politics"|"science"|"sports"|"technology"|"top"|"tourism"|"world"' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) return { success: false, error: 'NewsData.io API key not configured (set news.newsdata_api_key in .smallclaw/config.json)' };
|
||||
|
||||
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();
|
||||
|
||||
const params = new URLSearchParams({ apikey: apiKey });
|
||||
if (query) params.set('q', query);
|
||||
if (country) params.set('country', country);
|
||||
if (language) params.set('language', language);
|
||||
if (category) params.set('category', category);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${NEWSDATA_BASE}?${params}`, { signal: AbortSignal.timeout(15_000) });
|
||||
const data: any = await res.json();
|
||||
if (!res.ok || data.status !== 'success') {
|
||||
const msg = data?.results?.message || data?.message || `HTTP ${res.status}`;
|
||||
return { success: false, error: `NewsData.io error: ${msg}` };
|
||||
}
|
||||
const results: any[] = Array.isArray(data.results) ? data.results : [];
|
||||
return {
|
||||
success: true,
|
||||
stdout: formatArticles(results),
|
||||
data: { query, country, language, category, totalResults: data.totalResults, raw: results },
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import { audioTranscribeTool } from './audio-transcribe.js';
|
||||
import { pythonEvalTool } from './python.js';
|
||||
import { sqliteTool } from './sqlite.js';
|
||||
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool, weatherMapScreenshotTool } from './weather.js';
|
||||
import { newsSearchTool } from './news.js';
|
||||
import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js';
|
||||
import { kakaoSendTool } from './kakao.js';
|
||||
import { koreanLawSearchTool, koreanLawFetchTool, usCaseSearchTool } from './legal.js';
|
||||
@@ -222,6 +223,8 @@ class ToolRegistry {
|
||||
this.registerSafe(weatherCdsTool);
|
||||
this.registerSafe(weatherCmip6Tool);
|
||||
this.registerSafe(weatherMapScreenshotTool);
|
||||
// News (NewsData.io — real headlines with real publish dates, for news queries)
|
||||
this.registerSafe(newsSearchTool);
|
||||
// Email tools (IMAP + SMTP)
|
||||
this.registerSafe(emailListTool);
|
||||
this.registerSafe(emailReadTool);
|
||||
|
||||
+45
-1
@@ -327,6 +327,41 @@ function formatForecast(data: any, units: string): string {
|
||||
|
||||
// ── weather_search ────────────────────────────────────────────────────────────
|
||||
|
||||
// OpenWeather's `q` param disambiguates same-named cities by ISO-3166 country CODE
|
||||
// ("Rome,IT"), not by full country name — "Rome, Italy" silently resolves to whichever
|
||||
// "Rome" ranks first in its index (observed: Rome, Georgia, US instead of Rome, Italy).
|
||||
// The tool schema already documents the "City,CountryCode" format, but models often write
|
||||
// the full country name anyway — normalize the common ones rather than trust every caller
|
||||
// to follow the format, and verify the response actually landed in the right country.
|
||||
const COUNTRY_NAME_TO_ISO: Record<string, string> = {
|
||||
italy: 'IT', france: 'FR', germany: 'DE', spain: 'ES', portugal: 'PT',
|
||||
'united kingdom': 'GB', uk: 'GB', england: 'GB', netherlands: 'NL', belgium: 'BE',
|
||||
switzerland: 'CH', austria: 'AT', poland: 'PL', sweden: 'SE', norway: 'NO',
|
||||
denmark: 'DK', finland: 'FI', greece: 'GR', ireland: 'IE', 'czech republic': 'CZ',
|
||||
czechia: 'CZ', hungary: 'HU', russia: 'RU', china: 'CN', japan: 'JP',
|
||||
'south korea': 'KR', korea: 'KR', canada: 'CA', australia: 'AU', brazil: 'BR',
|
||||
mexico: 'MX', india: 'IN', turkey: 'TR', egypt: 'EG', 'south africa': 'ZA',
|
||||
vietnam: 'VN', thailand: 'TH', indonesia: 'ID', philippines: 'PH', singapore: 'SG',
|
||||
malaysia: 'MY', 'new zealand': 'NZ', ukraine: 'UA', romania: 'RO', bulgaria: 'BG',
|
||||
croatia: 'HR', slovakia: 'SK', slovenia: 'SI', iceland: 'IS', luxembourg: 'LU',
|
||||
'united states': 'US', usa: 'US', 'united states of america': 'US',
|
||||
};
|
||||
|
||||
// Returns the OpenWeather `q` string to send plus the ISO country code we expect back
|
||||
// (undefined if we couldn't determine one — no mismatch check in that case).
|
||||
function normalizeLocationForOpenWeather(location: string): { query: string; expectedCountry?: string } {
|
||||
const lastComma = location.lastIndexOf(',');
|
||||
if (lastComma < 0) return { query: location };
|
||||
const city = location.slice(0, lastComma).trim();
|
||||
const countryPart = location.slice(lastComma + 1).trim();
|
||||
if (/^[A-Za-z]{2}$/.test(countryPart)) {
|
||||
return { query: `${city},${countryPart.toUpperCase()}`, expectedCountry: countryPart.toUpperCase() };
|
||||
}
|
||||
const iso = COUNTRY_NAME_TO_ISO[countryPart.toLowerCase()];
|
||||
if (iso) return { query: `${city},${iso}`, expectedCountry: iso };
|
||||
return { query: location };
|
||||
}
|
||||
|
||||
export const weatherSearchTool = {
|
||||
name: 'weather_search',
|
||||
description: 'Get current weather or 5-day forecast for any city or coordinates using OpenWeather API. Returns temperature, humidity, wind, conditions, and more.',
|
||||
@@ -353,16 +388,25 @@ export const weatherSearchTool = {
|
||||
if (!location) return { success: false, error: 'location is required' };
|
||||
|
||||
const coordMatch = location.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
|
||||
const normalized = coordMatch ? null : normalizeLocationForOpenWeather(location);
|
||||
const locationParams: Record<string, string> = coordMatch
|
||||
? { lat: coordMatch[1], lon: coordMatch[2] }
|
||||
: { q: location };
|
||||
: { q: normalized!.query };
|
||||
|
||||
try {
|
||||
if (type === 'forecast') {
|
||||
const data = await owFetch('forecast', { ...locationParams, units, cnt: '40' });
|
||||
const gotCountry = String(data.city?.country || '').toUpperCase();
|
||||
if (normalized?.expectedCountry && gotCountry && gotCountry !== normalized.expectedCountry) {
|
||||
return { success: false, error: `Ambiguous city name: "${location}" resolved to ${data.city?.name}, ${gotCountry} instead of ${normalized.expectedCountry}. Retry with "City,${normalized.expectedCountry}" — if that still doesn't land in the right country, try a nearby larger city or include a state/region qualifier.` };
|
||||
}
|
||||
return { success: true, stdout: formatForecast(data, units), data: { location, type, units, raw: data } };
|
||||
} else {
|
||||
const data = await owFetch('weather', { ...locationParams, units });
|
||||
const gotCountry = String(data.sys?.country || '').toUpperCase();
|
||||
if (normalized?.expectedCountry && gotCountry && gotCountry !== normalized.expectedCountry) {
|
||||
return { success: false, error: `Ambiguous city name: "${location}" resolved to ${data.name}, ${gotCountry} instead of ${normalized.expectedCountry}. Retry with "City,${normalized.expectedCountry}" — if that still doesn't land in the right country, try a nearby larger city or include a state/region qualifier.` };
|
||||
}
|
||||
return { success: true, stdout: formatCurrent(data, units), data: { location, type, units, raw: data } };
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
||||
+9
-3
@@ -587,9 +587,15 @@ async function searchBrave(query: string, limit: number, apiKey: string): Promis
|
||||
}
|
||||
|
||||
// ── SearXNG (self-hosted/public metasearch, no key) ──────────────────────────
|
||||
async function searchSearXNG(query: string, limit: number, baseUrl: string): Promise<ToolResult> {
|
||||
async function searchSearXNG(query: string, limit: number, baseUrl: string, category?: string): Promise<ToolResult> {
|
||||
const base = baseUrl.replace(/\/+$/, '');
|
||||
const url = `${base}/search?q=${encodeURIComponent(query)}&format=json`;
|
||||
// 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 res = await fetch(url, {
|
||||
headers: { 'Accept': 'application/json', 'User-Agent': 'SmallClaw/1.0' },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
@@ -842,7 +848,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);
|
||||
case 'searxng': return searchSearXNG(args.query, limit, cfg.searxngUrl as string, isNewsSeekingQuery(args.query) ? 'news' : 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);
|
||||
|
||||
Reference in New Issue
Block a user