diff --git a/assets/js/vertex-search.js b/assets/js/vertex-search.js index 1d880998..3bf4944c 100644 --- a/assets/js/vertex-search.js +++ b/assets/js/vertex-search.js @@ -64,6 +64,27 @@ if (data.summary?.summaryText) { summaryTxt.textContent = data.summary.summaryText; summaryEl.style.display = 'block'; + + // Render numbered citation list + const refs = data.summary?.citations || []; + const validRefs = refs.filter(r => r.title || r.uri); + + if (validRefs.length > 0) { + const citationHtml = validRefs.map((ref, i) => ` +
+ [${i + 1}] + ${ref.uri + ? `${escapeHtml(ref.title || ref.uri)}` + : `${escapeHtml(ref.title || 'Unknown source')}` + } +
+ `).join(''); + + const citationsDiv = document.createElement('div'); + citationsDiv.className = 'search-citations mt-3'; + citationsDiv.innerHTML = '

Sources

' + citationHtml; + summaryEl.querySelector('.ai-summary').appendChild(citationsDiv); + } } // Show results @@ -73,16 +94,17 @@ } // Show result count - hitsEl.innerHTML = `

${data.results.length} results for ${escapeHtml(q)}

` + - data.results.map(r => ` -
-
- ${escapeHtml(r.title || 'Untitled')} -
- ${r.snippet ? `

${escapeHtml(r.snippet)}

` : ''} -

${escapeHtml(r.url)}

-
- `).join(''); + hitsEl.innerHTML = + `

${data.results.length} results for ${escapeHtml(q)}

` + + data.results.map(r => ` +
+
+ ${escapeHtml(r.title || 'Untitled')} +
+ ${r.snippet ? `

${escapeHtml(r.snippet)}

` : ''} +

${escapeHtml(r.url)}

+
+ `).join(''); } catch (err) { statusEl.textContent = 'Search error: ' + err.message; diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss index 5e26702e..2e23b2fd 100644 --- a/assets/scss/_styles_project.scss +++ b/assets/scss/_styles_project.scss @@ -145,3 +145,34 @@ padding: 1rem 0; color: #666; } + +.search-citations { + border-top: 1px solid #d0e4ff; + padding-top: 0.75rem; + margin-top: 0.75rem; + + .citations-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #1a73e8; + margin-bottom: 0.4rem; + } + } + + .search-citation { + font-size: 0.8rem; + margin-bottom: 0.25rem; + + .citation-number { + color: #1a73e8; + font-weight: 600; + margin-right: 0.4rem; + } + + a { + color: #444; + &:hover { color: #1a73e8; } + } + } \ No newline at end of file diff --git a/search-function/index.js b/search-function/index.js index f9308d2f..5d0c90ae 100644 --- a/search-function/index.js +++ b/search-function/index.js @@ -54,8 +54,13 @@ exports.search = async (req, res) => { summarySpec: { summaryResultCount: 5, includeCitations: true, + useSemanticChunks: true, + languageCode: 'en-US', modelPromptSpec: { preamble: buildPreamble(context) + }, + modelSpec: { + version: 'stable' } }, snippetSpec: { @@ -87,6 +92,7 @@ exports.search = async (req, res) => { // Add this right after const data = await response.json(); console.log('FIRST RESULT:', JSON.stringify(data.results?.[0], null, 2)); console.log('SUMMARY FULL:', JSON.stringify(data.summary, null, 2)); + console.log('CITATIONS:', JSON.stringify(data.summary?.summaryWithMetadata?.references?.[0])); console.log('RESPONSE KEYS:', Object.keys(data)); console.log('SUMMARY:', JSON.stringify(data.summary)); @@ -98,22 +104,43 @@ exports.search = async (req, res) => { const url = derived.link || null; const snippet = derived.snippets?.[0]?.snippet || null; - + // Strip angle brackets to prevent HTML/script tag injection in display text + const stripHtml = (str) => str ? str.replace(/[<>]/g, '') : null; + return { id: result.document?.id, title: derived.title || null, url, - snippet, + snippet: stripHtml(derived.snippets?.[0]?.snippet || null), section: url?.replace('https://interlisp.org/', '')?.split('/')?.[0] || '', }; }).filter(r => r?.url); + // Build a map of document ID to URL from search results + const docIdToUrl = {}; + (data.results || []).forEach(result => { + const id = result.document?.id; + const url = result.document?.derivedStructData?.link; + if (id && url) docIdToUrl[id] = url; + }); + + // Enrich references with URLs by matching document IDs + const references = (data.summary?.summaryWithMetadata?.references || []).map(ref => { + // Extract document ID from the full document path + const docId = ref.document?.split('/').pop(); + return { + title: ref.title, + uri: docIdToUrl[docId] || null, + docId + }; + }); + const summaryText = data.summary?.summaryText || null; - + res.json({ summary: summaryText ? { summaryText, - citations: data.summary?.summaryWithMetadata?.references || [] + citations: references } : null, results }); diff --git a/search-function/package.json b/search-function/package.json index 0d0ae0ac..d6b41b19 100644 --- a/search-function/package.json +++ b/search-function/package.json @@ -12,6 +12,7 @@ "type": "commonjs", "dependencies": { "@google-cloud/discoveryengine": "^2.6.0", + "@google-cloud/firestore": "^8.5.0", "google-auth-library": "^10.6.2" } } diff --git a/search-function/rateLimiter.js b/search-function/rateLimiter.js new file mode 100644 index 00000000..02ed8f78 --- /dev/null +++ b/search-function/rateLimiter.js @@ -0,0 +1,101 @@ +'use strict'; + +const { Firestore } = require('@google-cloud/firestore'); + +const db = new Firestore({ projectId: process.env.PROJECT_ID }); + +// Rate limit configuration +// Adjust these values based on your traffic patterns +const LIMITS = { + perIp: { + requests: 20, // max 20 requests per IP + windowSec: 60, // per 60 second window + }, + global: { + requests: 500, // max 500 total requests + windowSec: 60, // per 60 second window + } +}; + +/** + * Atomically check and increment a rate limit counter in Firestore. + * Returns { allowed: true/false, count: current count } + */ +async function checkLimit(key, limit) { + const ref = db.collection('rate_limits').doc(key); + const now = Date.now(); + const windowMs = limit.windowSec * 1000; + + try { + const result = await db.runTransaction(async t => { + const doc = await t.get(ref); + const data = doc.exists ? doc.data() : null; + + // Start a new window if first request or window has expired + if (!data || (now - data.windowStart) > windowMs) { + t.set(ref, { + count: 1, + windowStart: now, + updatedAt: now + }); + return { allowed: true, count: 1 }; + } + + // Window is active — check if over limit + if (data.count >= limit.requests) { + return { allowed: false, count: data.count }; + } + + // Increment counter + t.update(ref, { + count: Firestore.FieldValue.increment(1), + updatedAt: now + }); + return { allowed: true, count: data.count + 1 }; + }); + + return result; + + } catch (err) { + // Fail open — if Firestore is unavailable don't block searches + console.error('Rate limiter error:', err.message); + return { allowed: true, count: 0 }; + } +} + +/** + * Main rate limit check — runs IP and global checks in parallel. + * Returns { limited: false } or { limited: true, reason, retryAfter } + */ +async function isRateLimited(req) { + // Get client IP — Cloud Functions passes real IP in x-forwarded-for + const ip = req.headers['x-forwarded-for'] + ?.split(',')[0] + ?.trim() || 'unknown'; + + // Run both checks in parallel + const [ipCheck, globalCheck] = await Promise.all([ + checkLimit(`ip:${ip}`, LIMITS.perIp), + checkLimit('global', LIMITS.global), + ]); + + if (!ipCheck.allowed) { + return { + limited: true, + reason: 'Too many requests. Please wait a moment before searching again.', + retryAfter: LIMITS.perIp.windowSec + }; + } + + if (!globalCheck.allowed) { + return { + limited: true, + reason: 'Search service is temporarily busy. Please try again shortly.', + retryAfter: LIMITS.global.windowSec + }; + } + + return { limited: false }; +} + +module.exports = { isRateLimited };