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:
kim
2026-08-08 19:22:04 +09:00
co-authored by Claude Sonnet 5
parent bb8599574b
commit d9680fc96f
+49 -1
View File
@@ -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',