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:
kim
2026-07-12 00:27:56 +09:00
co-authored by Claude Sonnet 5
parent 3bf01d68a5
commit 21367eaecb
+61 -88
View File
@@ -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;