From 20f0db935a853a2865cece974229112f324b6f8d Mon Sep 17 00:00:00 2001 From: kim Date: Sun, 12 Jul 2026 00:27:03 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20web=5Fsearch=20=ED=8F=B4=EB=B0=B1=20?= =?UTF-8?q?=EC=B2=B4=EC=9D=B8=EC=9D=B4=20=EB=B9=88=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=A5=BC=20=EC=84=B1=EA=B3=B5=EC=9C=BC=EB=A1=9C=20=EC=98=A4?= =?UTF-8?q?=ED=8C=90=ED=95=98=EB=8D=98=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 executeWebSearch()가 provider 호출이 예외를 던지지 않으면 결과가 0건이어도 "성공"으로 간주하고 그 자리에서 반환해버려, 뒤에 있는 (더 잘 작동할 수도 있는) provider로 넘어가지 못했음. 오늘 시어엔진이 bing 타임아웃으로 0건을 반환했을 때 ollama_cloud가 멀쩡히 있었는데도 전혀 시도되지 않은 게 이 버그 때문이었음. - SearchProviderAttempt에 'empty' 상태 추가 (성공했지만 결과 없음) - 각 provider 호출 후 실제 결과 개수를 확인해서 0건이면 다음 provider로 계속 진행하도록 수정 - 전부 empty/failed로 끝나면 하드 에러 대신 가장 처음 나온 "성공했지만 빈 결과"를 우아하게 반환 (기존엔 여기까지 오면 무조건 에러) - ddg를 candidates 맨 뒤로 재배치 — Instant Answer API는 구조적으로 일반 검색을 지원 안 하고, HTML 스크래핑 폴백은 현재 이 서버 IP에서 DuckDuckGo 봇탐지에 막혀있어 거의 항상 빈손임 (html.duckduckgo.com, lite.duckduckgo.com, duckduckgo.com 전부 확인함) - 중복되던 6개의 거의 동일한 provider 분기를 runProvider() 디스패치 헬퍼 하나로 통합 Co-Authored-By: Claude Sonnet 5 --- src/tools/web.ts | 149 +++++++++++++++++++---------------------------- 1 file changed, 61 insertions(+), 88 deletions(-) diff --git a/src/tools/web.ts b/src/tools/web.ts index d542009..0fc3a3b 100644 --- a/src/tools/web.ts +++ b/src/tools/web.ts @@ -26,7 +26,7 @@ type StructuredFact = { id: number; claim: string; evidence_ids: number[]; sourc type SearchProvider = 'tavily' | 'google' | 'brave' | 'ddg' | 'ddg_html' | 'searxng' | 'ollama_cloud'; type SearchProviderAttempt = { provider: SearchProvider; - status: 'success' | 'failed' | 'skipped'; + status: 'success' | 'empty' | 'failed' | 'skipped'; reason?: string; duration_ms?: number; result_count?: number; @@ -813,7 +813,10 @@ export async function executeWebSearch(args: { query: string; max_results?: numb const cfg = getSearchConfig(); - const candidates: Array<'tavily' | 'searxng' | 'google' | 'brave' | 'ddg' | 'ollama_cloud'> = ['searxng', 'ollama_cloud', 'ddg', 'tavily', 'google', 'brave']; + // ddg sits last: its Instant Answer API only covers infobox-style queries and its + // HTML scrape fallback is routinely bot-walled (see searchDDGHtml) — it rarely adds + // value once searxng/ollama_cloud/tavily/google/brave have all had a shot. + const candidates: Array<'tavily' | 'searxng' | 'google' | 'brave' | 'ollama_cloud' | 'ddg'> = ['searxng', 'ollama_cloud', 'tavily', 'google', 'brave', 'ddg']; const providerOrder = [cfg.preferred, ...candidates.filter(p => p !== cfg.preferred)]; const diagnostics: SearchDiagnostics = { query: args.query, @@ -822,7 +825,24 @@ export async function executeWebSearch(args: { query: string; max_results?: numb attempted: [], }; + 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); + 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); + case 'ddg': return searchDDG(args.query, limit); + default: throw new Error(`unhandled provider: ${provider}`); + } + }; + let lastErr = null; + // A provider that returned HTTP-OK but zero results is kept as a last-resort answer — + // better than a hard error if every provider ends up empty for this query. + let bestEmpty: ToolResult | null = null; + let bestEmptyProvider: SearchProvider | null = null; + for (const provider of providerOrder) { if (provider === 'tavily' && !cfg.tavilyKey) { diagnostics.attempted.push({ provider, status: 'skipped', reason: 'missing_tavily_api_key' }); @@ -847,89 +867,27 @@ export async function executeWebSearch(args: { query: string; max_results?: numb const started = Date.now(); try { - if (provider === 'tavily') { - const res = await searchTavily(args.query, limit, cfg.tavilyKey as string); - await augmentEventContract(args.query, res); - const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0; - diagnostics.attempted.push({ - provider, - status: 'success', - duration_ms: Date.now() - started, - result_count: resultCount, - }); - diagnostics.selected_provider = 'tavily'; - res.data = { ...(res.data || {}), provider: 'tavily', search_diagnostics: diagnostics }; + const res = await runProvider(provider); + await augmentEventContract(args.query, res); + const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0; + const hasResults = res.success && resultCount > 0; + + diagnostics.attempted.push({ + provider, + status: hasResults ? 'success' : (res.success ? 'empty' : 'failed'), + duration_ms: Date.now() - started, + result_count: resultCount, + ...(!res.success && { reason: res.error }), + }); + + if (hasResults) { + diagnostics.selected_provider = provider; + res.data = { ...(res.data || {}), provider, search_diagnostics: diagnostics }; return res; } - if (provider === 'searxng') { - const res = await searchSearXNG(args.query, limit, cfg.searxngUrl as string); - await augmentEventContract(args.query, res); - const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0; - diagnostics.attempted.push({ - provider, - status: 'success', - duration_ms: Date.now() - started, - result_count: resultCount, - }); - diagnostics.selected_provider = 'searxng'; - res.data = { ...(res.data || {}), provider: 'searxng', search_diagnostics: diagnostics }; - return res; - } - if (provider === 'google') { - const res = await searchGoogle(args.query, limit, cfg.googleKey as string, cfg.googleCx as string); - await augmentEventContract(args.query, res); - const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0; - diagnostics.attempted.push({ - provider, - status: 'success', - duration_ms: Date.now() - started, - result_count: resultCount, - }); - diagnostics.selected_provider = 'google'; - res.data = { ...(res.data || {}), provider: 'google', search_diagnostics: diagnostics }; - return res; - } - if (provider === 'brave') { - const res = await searchBrave(args.query, limit, cfg.braveKey as string); - await augmentEventContract(args.query, res); - const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0; - diagnostics.attempted.push({ - provider, - status: 'success', - duration_ms: Date.now() - started, - result_count: resultCount, - }); - diagnostics.selected_provider = 'brave'; - res.data = { ...(res.data || {}), provider: 'brave', search_diagnostics: diagnostics }; - return res; - } - if (provider === 'ddg') { - const res = await searchDDG(args.query, limit); - await augmentEventContract(args.query, res); - const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0; - diagnostics.attempted.push({ - provider, - status: 'success', - duration_ms: Date.now() - started, - result_count: resultCount, - }); - diagnostics.selected_provider = 'ddg'; - res.data = { ...(res.data || {}), provider: 'ddg', search_diagnostics: diagnostics }; - return res; - } - if (provider === 'ollama_cloud') { - const res = await searchOllamaCloud(args.query, limit, cfg.ollamaApiKey as string); - await augmentEventContract(args.query, res); - const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0; - diagnostics.attempted.push({ - provider, - status: 'success', - duration_ms: Date.now() - started, - result_count: resultCount, - }); - diagnostics.selected_provider = 'ollama_cloud'; - res.data = { ...(res.data || {}), provider: 'ollama_cloud', search_diagnostics: diagnostics }; - return res; + if (res.success && !bestEmpty) { + bestEmpty = res; + bestEmptyProvider = provider; } } catch (err) { lastErr = err; @@ -942,20 +900,28 @@ export async function executeWebSearch(args: { query: string; max_results?: numb } } - // Final fallback if ddg path threw and wasn't already successful + // Final fallback: DDG HTML scrape (rarely succeeds — see comment on `candidates` above) const fallbackStarted = Date.now(); try { const res = await searchDDGHtml(args.query, limit); const resultCount = Array.isArray(res.data?.results) ? res.data.results.length : 0; + const hasResults = res.success && resultCount > 0; diagnostics.attempted.push({ provider: 'ddg_html', - status: 'success', + status: hasResults ? 'success' : (res.success ? 'empty' : 'failed'), duration_ms: Date.now() - fallbackStarted, result_count: resultCount, + ...(!res.success && { reason: res.error }), }); - diagnostics.selected_provider = 'ddg_html'; - res.data = { ...(res.data || {}), provider: 'ddg_html', search_diagnostics: diagnostics }; - return res; + if (hasResults) { + diagnostics.selected_provider = 'ddg_html'; + res.data = { ...(res.data || {}), provider: 'ddg_html', search_diagnostics: diagnostics }; + return res; + } + if (res.success && !bestEmpty) { + bestEmpty = res; + bestEmptyProvider = 'ddg_html'; + } } catch (err) { lastErr = err; diagnostics.attempted.push({ @@ -965,6 +931,13 @@ export async function executeWebSearch(args: { query: string; max_results?: numb duration_ms: Date.now() - fallbackStarted, }); } + + if (bestEmpty) { + diagnostics.selected_provider = bestEmptyProvider as SearchProvider; + bestEmpty.data = { ...(bestEmpty.data || {}), provider: bestEmptyProvider, search_diagnostics: diagnostics }; + return bestEmpty; + } + let errMsg = 'unknown error'; if (lastErr) { if (typeof lastErr === 'object' && 'message' in lastErr) errMsg = (lastErr as any).message;