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>
This commit is contained in:
parent
c8cb4cbbe4
commit
83329d0d58
2 changed files with 90 additions and 0 deletions
|
|
@ -76,6 +76,56 @@ app.get('/search', async (c) => {
|
|||
})
|
||||
})
|
||||
|
||||
// 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()
|
||||
|
|
|
|||
|
|
@ -63,6 +63,16 @@ const CSS = `
|
|||
}
|
||||
.results-logo { font-size: 20px; font-weight: 700; white-space: nowrap; color: var(--title); }
|
||||
.results-logo span { color: var(--accent); }
|
||||
|
||||
/* AI answer card */
|
||||
.answer-card { background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--accent); padding: 15px 18px; margin: 0 0 24px; }
|
||||
.answer-card.hidden { display: none; }
|
||||
.answer-label { font-size: 10px; letter-spacing: 2px; text-transform: uppercase; color: var(--accent); margin-bottom: 9px; }
|
||||
.answer-body { font-size: 14px; line-height: 1.65; color: var(--text); }
|
||||
.answer-body sup a { color: var(--accent); font-size: 10px; padding: 0 1px; text-decoration: none; }
|
||||
.answer-sources { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.answer-sources a { font-size: 11px; color: var(--muted); border: 1px solid var(--border); padding: 2px 7px; }
|
||||
.answer-sources a:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.results-form { display: flex; gap: 8px; flex: 1; }
|
||||
.results-input {
|
||||
flex: 1; background: var(--surface); border: 1px solid var(--border);
|
||||
|
|
@ -182,6 +192,36 @@ function resultsPage(q: string, data: any, page: number) {
|
|||
~${total.toLocaleString()} results
|
||||
${peers.length ? `<span class="peers">// ${peers.length} peer${peers.length !== 1 ? 's' : ''} federated</span>` : ''}
|
||||
</div>
|
||||
<div class="answer-card hidden" id="answer">
|
||||
<div class="answer-label">✦ AI answer</div>
|
||||
<div class="answer-body" id="answer-body"></div>
|
||||
<div class="answer-sources" id="answer-sources"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var q = ${JSON.stringify(q)};
|
||||
var card = document.getElementById('answer'),
|
||||
body = document.getElementById('answer-body'),
|
||||
srcEl = document.getElementById('answer-sources');
|
||||
function esc(t){ return String(t).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
fetch('/api/answer?q=' + encodeURIComponent(q))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d || !d.answer) return;
|
||||
var srcs = d.sources || [];
|
||||
var html = esc(d.answer).replace(/\\[(\\d+)\\]/g, function (m, n) {
|
||||
var s = srcs[parseInt(n) - 1];
|
||||
return s ? '<sup><a href="' + esc(s.url) + '" rel="noopener" title="' + esc(s.domain) + '">[' + n + ']</a></sup>' : '';
|
||||
});
|
||||
body.innerHTML = html;
|
||||
srcEl.innerHTML = srcs.map(function (s) {
|
||||
return '<a href="' + esc(s.url) + '" rel="noopener">' + esc(s.domain) + '</a>';
|
||||
}).join('');
|
||||
card.classList.remove('hidden');
|
||||
})
|
||||
.catch(function () {});
|
||||
})();
|
||||
</script>
|
||||
${results}
|
||||
${hits.length ? `<div class="pagination">${prevLink}${nextLink}</div>` : ''}
|
||||
<div class="footer">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue