feat: web_search에 검색결과 핵심답변 추출 단계 추가
메인 채팅 모델이 web_search 원본 스니펫에서 구체적 수치를 못 찾고 "검색결과에 없음"으로 답하는 사례 발견(2026-08-08, 실제 대화에서 확인) — 같은 검색결과를 ollama_web_search로 재질의하면 정확히 찾아냈음. 원인은 검색엔진 차이가 아니라(둘 다 동일한 폴백체인 사용) ollama_web_search가 "이 결과만 근거로 답하라"는 좁고 집중된 프롬프트로 별도 모델 호출을 한 번 더 거치기 때문이었음 — 메인 모델은 긴 시스템프롬프트+대화이력 속에서 검색결과를 곁다리로 처리하다 보니 놓친 것으로 추정. web_search에도 같은 패턴(executeWebSearchWithExtraction)을 적용 — 원본 검색결과는 그대로 유지(출처 인용용)하고 앞에 "[핵심 답변]" 추출 섹션을 붙임. 추출 실패/타임아웃(15초) 시 원본 결과만 반환해 기존 동작을 깨지 않도록 함. 실제 쿼리로 테스트 완료(Gemma 4 31B VRAM 요구사양 정확히 추출됨). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+49
-1
@@ -996,6 +996,54 @@ export async function executeWebSearch(args: { query: string; max_results?: numb
|
||||
};
|
||||
}
|
||||
|
||||
// web_search results go straight to the primary chat model, which has to pull a specific
|
||||
// fact (e.g. a VRAM number) out of noisy snippets while also juggling a long system prompt,
|
||||
// conversation history and other tool results — measured 2026-08-08: it missed a figure that
|
||||
// was plainly present in the raw results, answering "검색결과에 수치가 없다" when it was there.
|
||||
// ollama_web_search already solves this by re-asking a model a single narrow question
|
||||
// ("answer only from this text, say so if it's not there") with nothing else in its context,
|
||||
// which reliably surfaces the fact. Reuse that same pattern here — prepend a focused extraction
|
||||
// on top of the raw results (not instead of: citations still need the original list/URLs).
|
||||
async function extractAnswerFromResults(query: string, rawResults: string): Promise<string | null> {
|
||||
try {
|
||||
const { endpoint, model } = getOllamaConfig();
|
||||
const res = await fetch(`${endpoint}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: '아래 검색 결과만 근거로 질문에 답하세요. 검색 결과에 없는 내용은 추측하지 말고, 근거가 부족하면 그렇다고 말하세요. 2~4문장으로 간결하게.' },
|
||||
{ role: 'user', content: `질문: ${query}\n\n검색 결과:\n${rawResults}` },
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data: any = await res.json();
|
||||
const answer = String(data.message?.content || '').trim();
|
||||
return answer || null;
|
||||
} catch {
|
||||
// Extraction is a best-effort add-on — any failure (timeout, endpoint down) must fall
|
||||
// back to the raw results silently rather than break web_search itself.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeWebSearchWithExtraction(args: { query: string; max_results?: number }): Promise<ToolResult> {
|
||||
const res = await executeWebSearch(args);
|
||||
if (!res.success || !res.stdout) return res;
|
||||
|
||||
const extracted = await extractAnswerFromResults(args.query, res.stdout);
|
||||
if (!extracted) return res;
|
||||
|
||||
return {
|
||||
...res,
|
||||
stdout: `[핵심 답변]\n${extracted}\n\n[검색 결과 원문]\n${res.stdout}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── web_fetch: fetch a URL and return clean text ──────────────────────────────
|
||||
export async function executeWebFetch(args: { url: string; max_chars?: number }): Promise<ToolResult> {
|
||||
if (!args.url?.trim()) return { success: false, error: 'url is required' };
|
||||
@@ -1031,7 +1079,7 @@ export async function executeWebFetch(args: { url: string; max_chars?: number })
|
||||
export const webSearchTool = {
|
||||
name: 'web_search',
|
||||
description: 'Search the web. Uses Tavily or Brave API if configured in ~/.smallclaw/config.json (search.tavily_api_key / search.brave_api_key), otherwise falls back to DuckDuckGo (no key needed).',
|
||||
execute: executeWebSearch,
|
||||
execute: executeWebSearchWithExtraction,
|
||||
schema: {
|
||||
query: 'string (required) - Search query',
|
||||
max_results: 'number (optional, default 5) - Max results to return',
|
||||
|
||||
Reference in New Issue
Block a user