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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user