feat: 웹검색에 Ollama Cloud API 폴백 추가 + ollama_web_search 이중화

- searchOllama()를 예전 tool_calls 방식에서 "코드가 검색 먼저 실행 →
  모델은 결과만 요약" 방식으로 교체 (모델이 검색 스킵하고 환각 답변하던
  버그 수정)
- ollama.com/api/web_search 기반 새 provider(ollama_cloud) 추가,
  web_search 폴백 순서: searxng → ollama_cloud → ddg → tavily →
  google → brave
- web_search와 ollama_web_search가 searchForSynthesis() 공통 헬퍼로
  같은 폴백 체인을 공유하도록 통합 (기존엔 ollama_web_search가 DDG
  단일 의존이라 SearXNG 이중화 혜택을 못 받았음)
- search.ollama_api_key 설정 필드 추가 (config.json, types.ts,
  settings.ts REST 라우트). 실제 키 값은 vault:search.ollama_api_key
  참조로 저장 — 평문은 config.json에도 git 히스토리에도 남기지 않음
  (.smallclaw/vault/ AES-256-GCM 암호화 저장소, gitignore 대상)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-12 00:08:20 +09:00
co-authored by Claude Sonnet 5
parent cec930c69c
commit e356573228
4 changed files with 102 additions and 60 deletions
+2 -1
View File
@@ -265,7 +265,8 @@
"search_rigor": "verified",
"google_cx": "",
"ollama_model": "",
"searxng_url": "http://localhost:8888"
"searxng_url": "http://localhost:8888",
"ollama_api_key": "vault:search.ollama_api_key"
},
"agent_policy": {
"force_web_for_fresh": true,
+3 -1
View File
@@ -55,11 +55,12 @@ export function registerSettingsRoutes(app: Express): void {
brave_api_key: maskIfSet(cfg.brave_api_key),
ollama_model: cfg.ollama_model || '',
searxng_url: cfg.searxng_url || '',
ollama_api_key: maskIfSet(cfg.ollama_api_key),
});
});
app.post('/api/settings/search', (req, res) => {
const { preferred_provider, search_rigor, tavily_api_key, google_api_key, google_cx, brave_api_key, ollama_model, searxng_url } = req.body;
const { preferred_provider, search_rigor, tavily_api_key, google_api_key, google_cx, brave_api_key, ollama_model, searxng_url, ollama_api_key } = req.body;
const current = cm.getConfig() as any;
const isNew = (v: any) => v !== undefined && v !== '' && v !== '••••••••';
const newSearch = {
@@ -72,6 +73,7 @@ export function registerSettingsRoutes(app: Express): void {
...(isNew(brave_api_key) && { brave_api_key }),
...(ollama_model !== undefined && { ollama_model }),
...(searxng_url && { searxng_url }),
...(isNew(ollama_api_key) && { ollama_api_key }),
};
cm.updateConfig({ search: newSearch } as any);
res.json({ success: true });
+96 -58
View File
@@ -23,7 +23,7 @@ function stripHtml(html: string, preserveStructure = false): string {
type StructuredSource = { id: number; tier: 'A' | 'B' | 'C'; title: string; url: string; snippet: string; score: number };
type StructuredEvidence = { id: number; source_id: number; excerpt: string; score: number };
type StructuredFact = { id: number; claim: string; evidence_ids: number[]; source_ids: number[]; confidence: number };
type SearchProvider = 'tavily' | 'google' | 'brave' | 'ddg' | 'ddg_html' | 'searxng';
type SearchProvider = 'tavily' | 'google' | 'brave' | 'ddg' | 'ddg_html' | 'searxng' | 'ollama_cloud';
type SearchProviderAttempt = {
provider: SearchProvider;
status: 'success' | 'failed' | 'skipped';
@@ -33,8 +33,8 @@ type SearchProviderAttempt = {
};
type SearchDiagnostics = {
query: string;
preferred_provider: 'tavily' | 'google' | 'brave' | 'ddg' | 'searxng';
provider_order: Array<'tavily' | 'google' | 'brave' | 'ddg' | 'searxng'>;
preferred_provider: 'tavily' | 'google' | 'brave' | 'ddg' | 'searxng' | 'ollama_cloud';
provider_order: Array<'tavily' | 'google' | 'brave' | 'ddg' | 'searxng' | 'ollama_cloud'>;
attempted: SearchProviderAttempt[];
selected_provider?: SearchProvider;
};
@@ -463,7 +463,7 @@ function rankResults(query: string, results: SearchResultItem[]) {
// ── Load optional API keys from config ─────────────────────────────────────
// Cached with 5-minute TTL so config changes are picked up without restart.
type SearchConfig = { preferred: 'tavily' | 'google' | 'brave' | 'ddg' | 'searxng'; tavilyKey?: string; googleKey?: string; googleCx?: string; braveKey?: string; searxngUrl?: string };
type SearchConfig = { preferred: 'tavily' | 'google' | 'brave' | 'ddg' | 'searxng' | 'ollama_cloud'; tavilyKey?: string; googleKey?: string; googleCx?: string; braveKey?: string; searxngUrl?: string; ollamaApiKey?: string };
let _searchConfigCache: { value: SearchConfig; expiresAt: number } | null = null;
function getSearchConfig(): SearchConfig {
@@ -474,7 +474,7 @@ function getSearchConfig(): SearchConfig {
const cm = getConfig();
const data = cm.getConfig();
const preferredRaw = String(data.search?.preferred_provider || 'ddg').toLowerCase();
const preferred = (['tavily', 'google', 'brave', 'ddg', 'searxng'].includes(preferredRaw) ? preferredRaw : 'ddg') as SearchConfig['preferred'];
const preferred = (['tavily', 'google', 'brave', 'ddg', 'searxng', 'ollama_cloud'].includes(preferredRaw) ? preferredRaw : 'ddg') as SearchConfig['preferred'];
const searxngRaw = typeof data.search?.searxng_url === 'string' ? data.search.searxng_url.trim().replace(/\/+$/, '') : '';
value = {
preferred,
@@ -483,6 +483,7 @@ function getSearchConfig(): SearchConfig {
googleCx: data.search?.google_cx,
braveKey: cm.resolveSecret(data.search?.brave_api_key),
searxngUrl: searxngRaw || undefined,
ollamaApiKey: cm.resolveSecret(data.search?.ollama_api_key),
};
} catch {}
_searchConfigCache = { value, expiresAt: now + 5 * 60_000 };
@@ -613,6 +614,34 @@ async function searchSearXNG(query: string, limit: number, baseUrl: string): Pro
};
}
// ── Ollama Cloud web search API (ollama.com, requires OLLAMA_API_KEY) ─────────
async function searchOllamaCloud(query: string, limit: number, apiKey: string): Promise<ToolResult> {
const res = await fetch('https://ollama.com/api/web_search', {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`Ollama web search HTTP ${res.status}`);
const data: any = await res.json();
const raw: SearchResultItem[] = (data.results || []).slice(0, limit).map((r: any) => ({
title: r.title || '',
url: r.url || '',
snippet: r.content || '',
}));
const ranked = rankResults(query, raw);
const answer = buildDirectPriceAnswer(query, ranked);
return {
success: true,
data: { query, results: ranked, answer: answer || undefined },
stdout: (answer ? `${answer}\n\n` : '') + ranked.map((r, i) =>
`[${i + 1}] ${r.title}\n ${r.url}\n ${r.snippet.slice(0, 400)}`
).join('\n\n'),
};
}
// ── DuckDuckGo JSON endpoint (no key, more stable than HTML scrape) ───────────
async function searchDDG(query: string, limit: number): Promise<ToolResult> {
// DDG instant answer API — gives structured results without scraping HTML
@@ -712,76 +741,67 @@ async function searchDDGHtml(query: string, limit: number): Promise<ToolResult>
};
}
// ── Shared fallback chain for search-then-synthesize callers (searxng → ollama_cloud → ddg → ddg_html) ──
async function searchForSynthesis(query: string, limit: number): Promise<ToolResult> {
const cfg = getSearchConfig();
if (cfg.searxngUrl) {
try {
const res = await searchSearXNG(query, limit, cfg.searxngUrl);
if (res.success && res.stdout) return res;
} catch {}
}
if (cfg.ollamaApiKey) {
try {
const res = await searchOllamaCloud(query, limit, cfg.ollamaApiKey);
if (res.success && res.stdout) return res;
} catch {}
}
try {
const res = await searchDDG(query, limit);
if (res.success) return res;
} catch {}
return searchDDGHtml(query, limit);
}
// ── Ollama web-search (model-assisted) ────────────────────────────────────────
// Sends query to Ollama model with web_search tool. Model calls the tool,
// we execute DDG, feed results back, model synthesizes final answer.
// Search is executed deterministically in code FIRST, then the model only
// summarizes the real results — it is never given the option to answer from
// its own parametric memory instead of searching. An earlier version offered
// the model a web_search tool and let it decide whether to call it; when it
// skipped the tool (common with small local models), the code returned the
// model's unsourced guess as if it were a search result (e.g. a fabricated
// weather report with a date over a year stale). Never let the model choose
// whether to search — only let it choose what to say about real results.
async function searchOllama(query: string, limit: number, endpoint: string, model: string): Promise<ToolResult> {
const webSearchToolDef = {
type: 'function',
function: {
name: 'web_search',
description: 'Search the web for real-time information',
parameters: {
type: 'object',
properties: { query: { type: 'string', description: 'search query' } },
required: ['query'],
},
},
};
// Step 1: ask model — it should emit a tool_call for web_search
const chatRes = await fetch(`${endpoint}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: [{ role: 'user', content: query }],
tools: [webSearchToolDef],
stream: false,
}),
signal: AbortSignal.timeout(30_000),
});
if (!chatRes.ok) throw new Error(`Ollama chat HTTP ${chatRes.status}`);
const chatData: any = await chatRes.json();
const toolCalls = chatData.message?.tool_calls as any[] | undefined;
// If model answered directly (no tool call), return as-is
if (!toolCalls?.length) {
const content = String(chatData.message?.content || '');
if (!content) throw new Error('Ollama returned empty response');
return { success: true, stdout: content, data: { query, provider: 'ollama', answer: content } };
const searchRes = await searchForSynthesis(query, limit);
if (!searchRes.success) {
return { success: false, error: `검색 실패: ${searchRes.error}` };
}
if (!searchRes.stdout) {
return { success: true, stdout: `"${query}"에 대한 검색 결과가 없습니다.`, data: { query, provider: 'ollama', searchQuery: query } };
}
// Step 2: execute the web_search tool call with DDG
const searchQuery = toolCalls[0]?.function?.arguments?.query || query;
const searchRes = await searchDDG(searchQuery, limit);
const toolResultContent = searchRes.success
? searchRes.stdout || ''
: `검색 실패: ${searchRes.error}`;
// Step 3: send tool result back, model synthesizes
const finalRes = await fetch(`${endpoint}/api/chat`, {
const synthRes = await fetch(`${endpoint}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: [
{ role: 'user', content: query },
{ role: 'assistant', content: '', tool_calls: toolCalls },
{ role: 'tool', content: toolResultContent, name: 'web_search' },
{ role: 'system', content: '아래 검색 결과만 근거로 질문에 답하세요. 검색 결과에 없는 내용은 추측하지 말고, 근거가 부족하면 그렇다고 말하세요.' },
{ role: 'user', content: `질문: ${query}\n\n검색 결과:\n${searchRes.stdout}` },
],
stream: false,
}),
signal: AbortSignal.timeout(30_000),
});
if (!finalRes.ok) throw new Error(`Ollama synthesis HTTP ${finalRes.status}`);
const finalData: any = await finalRes.json();
const answer = String(finalData.message?.content || toolResultContent);
if (!synthRes.ok) throw new Error(`Ollama synthesis HTTP ${synthRes.status}`);
const synthData: any = await synthRes.json();
const answer = String(synthData.message?.content || searchRes.stdout);
return {
success: true,
stdout: answer,
data: { query, provider: 'ollama', searchQuery, answer },
data: { query, provider: 'ollama', searchQuery: query, answer },
};
}
@@ -793,7 +813,7 @@ export async function executeWebSearch(args: { query: string; max_results?: numb
const cfg = getSearchConfig();
const candidates: Array<'tavily' | 'searxng' | 'google' | 'brave' | 'ddg'> = ['searxng', 'ddg', 'tavily', 'google', 'brave'];
const candidates: Array<'tavily' | 'searxng' | 'google' | 'brave' | 'ddg' | 'ollama_cloud'> = ['searxng', 'ollama_cloud', 'ddg', 'tavily', 'google', 'brave'];
const providerOrder = [cfg.preferred, ...candidates.filter(p => p !== cfg.preferred)];
const diagnostics: SearchDiagnostics = {
query: args.query,
@@ -820,6 +840,10 @@ export async function executeWebSearch(args: { query: string; max_results?: numb
diagnostics.attempted.push({ provider, status: 'skipped', reason: 'missing_searxng_url' });
continue;
}
if (provider === 'ollama_cloud' && !cfg.ollamaApiKey) {
diagnostics.attempted.push({ provider, status: 'skipped', reason: 'missing_ollama_api_key' });
continue;
}
const started = Date.now();
try {
@@ -893,6 +917,20 @@ export async function executeWebSearch(args: { query: string; max_results?: numb
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;
}
} catch (err) {
lastErr = err;
diagnostics.attempted.push({
+1
View File
@@ -350,6 +350,7 @@ export interface SmallClawConfig {
google_cx?: string;
brave_api_key?: string;
searxng_url?: string;
ollama_api_key?: string;
search_rigor?: string;
pubmed_api_key?: string;
};