1/** 2 * services/clientSearch.js 3 * --------------------------------------------------------------------------- 4 * 100% Client-Side Search Grounding for eWith.me AI Chat 5 * 6 * Guarantees: 7 * 1. Zero Server Contact: Queries go directly from the user's browser to 8 * DuckDuckGo (via JSONP) or Wikipedia (via CORS-open API). The eWith.me 9 * server never sees, logs, or proxies the search queries. 10 * 2. Zero Latency for Unrelated Queries: A high-performance regex classifier 11 * evaluates input in < 0.1ms. Coding, math, rewriting, and timeless 12 * questions bypass search immediately with 0ms added delay. 13 * 3. Session Caching: In-memory Map prevents redundant network requests. 14 * --------------------------------------------------------------------------- 15 */ 16 17// In-memory cache for the current browser session (30-minute TTL) 18const searchCache = new Map(); 19const CACHE_TTL_MS = 30 * 60 * 1000; 20 21/** 22 * High-speed heuristic intent classifier (< 0.1ms). 23 * Determines if a prompt needs real-time / temporal grounding. 24 * 25 * @param {string} query 26 * @returns {boolean} true if web search should be executed; false to bypass. 27 */ 28export function shouldSearchWeb(query) { 29 if (!query || typeof query !== 'string') return false; 30 const trimmed = query.trim(); 31 if (trimmed.length < 5) return false; 32 33 // 1. Instant Bypass Rules (Zero-latency fast paths) 34 // Code snippets, programming requests, and syntax markers 35 const hasCodeMarkers = /```|[\{\}\[\];=>]|\b(const|let|var|function|def|class|import|from|return|console\.log|SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|curl|npm|pip|docker)\b/.test(trimmed); 36 if (hasCodeMarkers) return false; 37 38 // Text transformation / editing operations 39 const isTransformation = /^(paraphrase|rewrite|summarize|translate|proofread|correct|fix grammar|rephrase|convert to|make this|format this|give me bullet points)\b/i.test(trimmed); 40 if (isTransformation) return false; 41 42 // Math, arithmetic, or formulas 43 const isMath = /^[\d\s\+\-\*\/\^\(\)\.\=\%\$]+$/.test(trimmed) || /\b(calculate|derivative|integral|matrix|solve for)\b/i.test(trimmed); 44 if (isMath) return false; 45 46 // 2. Temporal & Live Search Trigger Patterns 47 // Explicit mentions of current/future years (2024 to 2039) 48 if (/\b(202[4-9]|203\d)\b/.test(trimmed)) return true; 49 50 // Temporal adverbs & keywords 51 if (/\b(current|currently|latest|recent|recently|today|now|newest|upcoming|live|breaking|present)\b/i.test(trimmed)) return true; 52 53 // Question patterns about people, roles, or dynamic state 54 if (/\b(who is (the |currently )?(president|prime minister|ceo|leader|head|founder|champion|winner|director|dean|chief))\b/i.test(trimmed)) return true; 55 56 // Breaking news or status inquiries 57 if (/\b(what happened (to|in|at)|news (about|on|regarding|today)|price of|stock price|status of|release date|when (is|was|will))\b/i.test(trimmed)) return true; 58 59 // College / educational live information 60 if (/\b(ranking|admission dates|application deadline|cutoff score)\b/i.test(trimmed)) return true; 61 62 // Default: Timeless questions (concepts, explanations, philosophy, history) bypass search 63 return false; 64} 65 66/** 67 * Direct Client-Side DuckDuckGo Query via JSONP. 68 * Uses <script> tag injection to bypass browser CORS restrictions. 69 * Zero server touch: browser communicates directly with api.duckduckgo.com. 70 * 71 * @param {string} query 72 * @param {number} timeoutMs 73 * @returns {Promise<Object|null>} 74 */ 75export function searchDuckDuckGoClient(query, timeoutMs = 1500) { 76 return new Promise((resolve) => { 77 if (typeof window === 'undefined' || typeof document === 'undefined') { 78 return resolve(null); 79 } 80 81 const cbName = `ddg_cb_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; 82 let scriptEl = null; 83 let timer = null; 84 85 const cleanup = () => { 86 if (timer) clearTimeout(timer); 87 if (scriptEl && scriptEl.parentNode) { 88 scriptEl.parentNode.removeChild(scriptEl); 89 } 90 try { 91 delete window[cbName]; 92 } catch (_) { 93 window[cbName] = undefined; 94 } 95 }; 96 97 timer = setTimeout(() => { 98 cleanup(); 99 resolve(null); 100 }, timeoutMs); 101 102 window[cbName] = (data) => { 103 cleanup(); 104 if (!data) return resolve(null); 105 106 const heading = data.Heading || ''; 107 const abstract = data.AbstractText || data.Abstract || ''; 108 const answer = data.Answer || ''; 109 const definition = data.Definition || ''; 110 const url = data.AbstractURL || ''; 111 112 // Collect related topic snippets (up to 3) 113 const topics = []; 114 if (Array.isArray(data.RelatedTopics)) { 115 for (const item of data.RelatedTopics) { 116 if (topics.length >= 3) break; 117 if (item.Text && item.FirstURL) { 118 topics.push({ text: item.Text, url: item.FirstURL }); 119 } else if (Array.isArray(item.Topics)) { 120 for (const sub of item.Topics) { 121 if (topics.length >= 3) break; 122 if (sub.Text && sub.FirstURL) { 123 topics.push({ text: sub.Text, url: sub.FirstURL }); 124 } 125 } 126 } 127 } 128 } 129 130 if (!abstract && !answer && !definition && topics.length === 0) { 131 return resolve(null); 132 } 133 134 resolve({ 135 heading, 136 abstract, 137 answer, 138 definition, 139 url, 140 topics 141 }); 142 }; 143 144 scriptEl = document.createElement('script'); 145 scriptEl.src = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1&callback=${cbName}`; 146 scriptEl.async = true; 147 scriptEl.onerror = () => { 148 cleanup(); 149 resolve(null); 150 }; 151 152 document.head.appendChild(scriptEl); 153 }); 154} 155 156/** 157 * Direct Client-Side Wikipedia Search via CORS-open API. 158 * Uses native fetch with origin=* parameter. 159 * 160 * @param {string} query 161 * @param {number} timeoutMs 162 * @returns {Promise<Array<{title: string, snippet: string, url: string}>|null>} 163 */ 164export async function searchWikipediaClient(query, timeoutMs = 1200) { 165 if (typeof fetch === 'undefined') return null; 166 167 const controller = new AbortController(); 168 const timer = setTimeout(() => controller.abort(), timeoutMs); 169 170 try { 171 const url = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&origin=*`; 172 const res = await fetch(url, { signal: controller.signal }); 173 clearTimeout(timer); 174 175 if (!res.ok) return null; 176 const data = await res.json(); 177 const results = data?.query?.search || []; 178 if (results.length === 0) return null; 179 180 return results.slice(0, 2).map(r => ({ 181 title: r.title, 182 snippet: (r.snippet || '').replace(/<[^>]+>/g, '').trim(), 183 url: `https://en.wikipedia.org/wiki/${encodeURIComponent(r.title.replace(/\s+/g, '_'))}` 184 })); 185 } catch (_) { 186 clearTimeout(timer); 187 return null; 188 } 189} 190 191/** 192 * Orchestrates direct client-side grounding: 193 * 1. Checks in-memory cache. 194 * 2. Queries DuckDuckGo JSONP. 195 * 3. Falls back or supplements with Wikipedia if needed. 196 * 4. Formats clean markdown context block for prompt injection. 197 * 198 * @param {string} query 199 * @returns {Promise<string|null>} Formatted grounding text, or null if nothing found. 200 */ 201export async function getLiveGrounding(query) { 202 if (!query || typeof query !== 'string') return null; 203 const cleanKey = query.trim().toLowerCase(); 204 205 // Check cache 206 const cached = searchCache.get(cleanKey); 207 if (cached && (Date.now() - cached.timestamp < CACHE_TTL_MS)) { 208 return cached.content; 209 } 210 211 try { 212 // Run DDG JSONP direct search 213 const ddgResult = await searchDuckDuckGoClient(cleanKey, 1500); 214 let facts = []; 215 let sources = []; 216 217 if (ddgResult) { 218 if (ddgResult.heading) facts.push(`Subject: ${ddgResult.heading}`); 219 if (ddgResult.answer) facts.push(`Instant Answer: ${ddgResult.answer}`); 220 if (ddgResult.abstract) facts.push(`Summary: ${ddgResult.abstract}`); 221 if (ddgResult.definition) facts.push(`Definition: ${ddgResult.definition}`); 222 if (ddgResult.url) sources.push(ddgResult.url); 223 224 for (const t of (ddgResult.topics || [])) { 225 facts.push(`- ${t.text}`); 226 if (t.url && !sources.includes(t.url)) sources.push(t.url); 227 } 228 } 229 230 // If DDG returned insufficient details, supplement with Wikipedia 231 if (facts.length < 2) { 232 const wikiResults = await searchWikipediaClient(cleanKey, 1200); 233 if (wikiResults && wikiResults.length > 0) { 234 for (const w of wikiResults) { 235 facts.push(`[Wikipedia: ${w.title}] ${w.snippet}`); 236 if (w.url && !sources.includes(w.url)) sources.push(w.url); 237 } 238 } 239 } 240 241 if (facts.length === 0) { 242 return null; 243 } 244 245 const todayStr = new Date().toLocaleDateString('en-US', { 246 weekday: 'long', 247 year: 'numeric', 248 month: 'long', 249 day: 'numeric' 250 }); 251 252 const formattedContext = [ 253 `[Real-Time Verified Knowledge | Current Date: ${todayStr}]`, 254 facts.join('\n'), 255 sources.length > 0 ? `Verified Sources: ${sources.join(', ')}` : '', 256 `[Instruction: Use the current facts above to give an accurate, up-to-date answer. Cite sources if appropriate.]`
257 ].filter(Boolean).join('\n\n'); 258 259 // Store in cache 260 searchCache.set(cleanKey, { 261 content: formattedContext, 262 timestamp: Date.now() 263 }); 264 265 return formattedContext; 266 } catch (err) { 267 console.warn('[ClientSearch] Grounding error (skipped):', err); 268 return null; 269 } 270}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.