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) => `
+
+ `).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 => `
-
-
- ${r.snippet ? `
${escapeHtml(r.snippet)}
` : ''}
-
${escapeHtml(r.url)}
-
- `).join('');
+ hitsEl.innerHTML =
+ `${data.results.length} results for ${escapeHtml(q)}
` +
+ data.results.map(r => `
+
+
+ ${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 };