search-engine/api/src/index.js
King Omar 83329d0d58 Add AI answer box: cited Claude RAG over the index
API: /answer retrieves top-6 from Meili and has Claude (haiku-4.5) synthesize
a 2-4 sentence cited answer over ONLY those sources (no hallucinated facts/URLs),
with a 1h in-memory cache. Frontend: async answer card above results with inline
[n] citations linking to sources — loads without blocking the results render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 02:30:07 +10:00

165 lines
5.3 KiB
JavaScript

import { Hono } from 'hono'
import { serve } from '@hono/node-server'
const app = new Hono()
const PORT = parseInt(process.env.PORT || '3000')
const MEILI_URL = process.env.MEILI_URL || 'http://localhost:7700'
const MEILI_KEY = process.env.MEILI_KEY || 'masterKey'
const NODE_ID = process.env.NODE_ID || 'main'
// In-memory peer registry { id, url, lastSeen }
const peers = new Map()
async function meiliSearch(q, offset = 0, limit = 10) {
const res = await fetch(`${MEILI_URL}/indexes/pages/search`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${MEILI_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
q,
limit,
offset,
attributesToHighlight: ['title', 'description'],
highlightPreTag: '<mark>',
highlightPostTag: '</mark>',
attributesToRetrieve: ['url', 'domain', 'title', 'description', 'crawledAt'],
}),
})
return res.json()
}
async function queryPeer(peer, q) {
try {
const res = await fetch(`${peer.url}/search?q=${encodeURIComponent(q)}&local=1`, {
signal: AbortSignal.timeout(3000),
headers: { 'X-Peer-Id': NODE_ID },
})
const data = await res.json()
return data.hits ?? []
} catch {
return []
}
}
// Search endpoint — fans out to peers when not local
app.get('/search', async (c) => {
const q = c.req.query('q')?.trim()
const page = Math.max(0, parseInt(c.req.query('page') ?? '0'))
const local = c.req.query('local') === '1'
if (!q) return c.json({ error: 'missing q' }, 400)
const offset = page * 10
const local$ = meiliSearch(q, offset)
const peer$ = local ? [] : [...peers.values()].map(p => queryPeer(p, q))
const [localResult, ...peerResults] = await Promise.all([local$, ...peer$])
// Deduplicate and merge peer hits
const seen = new Set(localResult.hits?.map(h => h.url) ?? [])
const peerHits = peerResults
.flat()
.filter(h => !seen.has(h.url))
.slice(0, 5)
const hits = [...(localResult.hits ?? []), ...peerHits]
return c.json({
hits,
total: (localResult.estimatedTotalHits ?? 0) + peerHits.length,
page,
query: q,
nodeId: NODE_ID,
peers: [...peers.keys()],
})
})
// AI answer box — cited RAG over the index with Claude.
const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY
const ANSWER_MODEL = process.env.ANSWER_MODEL || 'claude-haiku-4-5'
const answerCache = new Map() // q -> { at, payload }
app.get('/answer', async (c) => {
const q = c.req.query('q')?.trim()
if (!q) return c.json({ error: 'missing q' }, 400)
if (!ANTHROPIC_KEY) return c.json({ error: 'answers disabled' }, 503)
const key = q.toLowerCase()
const cached = answerCache.get(key)
if (cached && Date.now() - cached.at < 3_600_000) return c.json(cached.payload)
const result = await meiliSearch(q, 0, 6)
const hits = result.hits ?? []
if (hits.length === 0) return c.json({ answer: null, sources: [], query: q })
const sources = hits.map((h, i) => ({
n: i + 1, title: h.title, url: h.url, domain: h.domain,
snippet: (h.description || '').slice(0, 300),
}))
const context = sources.map(s => `[${s.n}] ${s.title}${s.domain}\n${s.snippet}`).join('\n\n')
try {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': ANTHROPIC_KEY,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: ANSWER_MODEL,
max_tokens: 400,
system: 'You are a search answer engine over the open web. Answer the query in 2-4 concise sentences using ONLY the numbered sources. Cite claims inline like [1][2]. If the sources do not contain the answer, say so briefly. Never invent facts or URLs.',
messages: [{ role: 'user', content: `Query: ${q}\n\nSources:\n${context}` }],
}),
signal: AbortSignal.timeout(20000),
})
const data = await res.json()
const answer = data.content?.map(b => b.text).join('') ?? null
const payload = { answer, sources, query: q, model: ANSWER_MODEL }
answerCache.set(key, { at: Date.now(), payload })
return c.json(payload)
} catch (e) {
return c.json({ answer: null, sources, query: q, error: e.message }, 502)
}
})
// P2P: register as peer
app.post('/peers/register', async (c) => {
const { id, url } = await c.req.json()
if (!id || !url) return c.json({ error: 'id and url required' }, 400)
peers.set(id, { id, url, lastSeen: Date.now() })
return c.json({ ok: true, peerId: NODE_ID, peers: [...peers.keys()] })
})
// P2P: list peers
app.get('/peers', (c) => {
return c.json({ nodeId: NODE_ID, peers: [...peers.values()] })
})
// Health
app.get('/health', async (c) => {
try {
const res = await fetch(`${MEILI_URL}/health`, {
headers: { 'Authorization': `Bearer ${MEILI_KEY}` }
})
const meili = await res.json()
return c.json({ ok: true, meili, nodeId: NODE_ID })
} catch (e) {
return c.json({ ok: false, error: e.message }, 503)
}
})
// Stats
app.get('/stats', async (c) => {
const res = await fetch(`${MEILI_URL}/indexes/pages/stats`, {
headers: { 'Authorization': `Bearer ${MEILI_KEY}` }
})
const stats = await res.json()
return c.json({ ...stats, peers: peers.size, nodeId: NODE_ID })
})
serve({ fetch: app.fetch, port: PORT })
console.log(`Search API running on :${PORT} [node=${NODE_ID}]`)