chatoai-backend / src /search.js
Keyurjotaniya007's picture
web search: use Tavily instead of DuckDuckGo
2a1fd7b
Raw
History Blame Contribute Delete
3.48 kB
// ----------------------------------------------------------------------------
// Web search provider β€” Tavily (https://tavily.com).
//
// WHY THIS CHANGED: the old version scraped DuckDuckGo directly. DuckDuckGo
// blocks datacenter IPs (Hugging Face, Render, Fly, etc.), so in production it
// returned ZERO results ({"sources":[],"notes":""}). Tavily is a search API
// built for LLMs: it returns clean, relevant content snippets per result, so we
// no longer scrape or fetch pages ourselves β€” it's faster and reliable.
//
// SETUP (one-time):
// 1. Get a free API key at https://app.tavily.com (1,000 searches/month,
// no credit card). It looks like: tvly-xxxxxxxxxxxxxxxx
// 2. Add it to your Hugging Face Space as a SECRET named TAVILY_API_KEY
// (Space β†’ Settings β†’ Variables and secrets β†’ New secret).
//
// The exported interface (webSearch, buildSourceNotes, domainOf) is unchanged,
// so server.js keeps working without edits.
// ----------------------------------------------------------------------------
const TAVILY_API_KEY = process.env.TAVILY_API_KEY || '';
const TAVILY_URL = 'https://api.tavily.com/search';
async function fetchWithTimeout(url, init = {}, timeoutMs = 15000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(id);
}
}
export function domainOf(url) {
return String(url).replace(/^https?:\/\//, '').split('/')[0];
}
// Query Tavily -> [{ title, url, snippet }]
export async function webSearch(query, maxResults = 6) {
const q = String(query || '').trim();
if (!q) return [];
if (!TAVILY_API_KEY) {
console.warn('[search] TAVILY_API_KEY is not set β€” web search disabled.');
return [];
}
try {
const res = await fetchWithTimeout(TAVILY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TAVILY_API_KEY}`,
},
body: JSON.stringify({
query: q,
max_results: Math.min(Math.max(maxResults, 1), 10),
search_depth: 'basic',
include_answer: false,
include_raw_content: false,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`tavily HTTP ${res.status} ${text.slice(0, 200)}`);
}
const data = await res.json();
const results = Array.isArray(data.results) ? data.results : [];
return results.map((r) => ({
title: r.title || domainOf(r.url || ''),
url: r.url || '',
snippet: String(r.content || '').trim(),
}));
} catch (e) {
console.warn('[search] tavily failed:', e.message);
return [];
}
}
// Turn results into numbered "source notes" the on-device model cites as [1],[2].
// Tavily already returns the relevant content per result, so there is nothing to
// fetch β€” we just format. (Extra args kept for signature compatibility.)
export async function buildSourceNotes(
query,
results,
_fetchTopK = 4,
_maxFactsPerSource = 4,
maxTotalChars = 9000,
) {
const blocks = (results || []).map((r, i) => {
const facts = String(r.snippet || '').replace(/\s+/g, ' ').trim();
return (
`[${i + 1}] Title: ${r.title}\nURL: ${r.url}\nExtracted facts:\n` +
`- ${facts || '(no snippet returned)'}`
);
});
return blocks.join('\n\n').slice(0, maxTotalChars);
}