fix: web_search 폴백 체인이 빈 결과를 성공으로 오판하던 버그 수정
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 <noreply@anthropic.com>
This commit is contained in:
+61
-88
@@ -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<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 '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;
|
||||
|
||||
Reference in New Issue
Block a user