pulse-ai-utils
Version:
Utility functions and helpers for AI-powered applications
462 lines • 27.8 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const geohash = __importStar(require("ngeohash"));
const MEMORY_CACHE = [];
const COLLECTION = 'query_cache';
const DB_ID = (process.env.QUERY_CACHE_DB_ID || 'pulse').trim().replace(/[\r\n\t]/g, '');
const DAY_MS = 24 * 60 * 60 * 1000;
function levenshtein(a, b) {
const matrix = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++)
matrix[i][0] = i;
for (let j = 0; j <= b.length; j++)
matrix[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
}
}
return matrix[a.length][b.length];
}
function similarity(a, b) {
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0)
return 1;
const dist = levenshtein(a, b);
return 1 - dist / maxLen;
}
class QueryCache {
/**
* Get cached results in batches for streaming
*/
async getCachedResultsBatch(prompt, location, batchSize = 5, lastScore = 1.0) {
if (process.env.NODE_ENV === 'test' || process.env.OPENAI_STUB_TEST === '1') {
// For testing, return memory cache results
const results = MEMORY_CACHE
.filter(entry => {
const sim = similarity(prompt, entry.prompt);
return sim < lastScore && sim >= 0.9 &&
entry.location.area === location.area &&
entry.location.buttonClickCount === location.buttonClickCount;
})
.map(entry => ({
data: entry.result,
score: similarity(prompt, entry.prompt)
}))
.sort((a, b) => b.score - a.score)
.slice(0, batchSize);
return results;
}
// Build Firestore query filters - simplified to avoid index issues
// We'll filter by area first, then do client-side filtering for other fields
// Note: In query_cache collection, area is nested under location
const queryFilters = {
'location.area': location.area
};
// @ts-ignore
const { default: firestoreService } = await Promise.resolve().then(() => __importStar(require('../firestore/firestore')));
try {
// Note: getDocumentsByFields will use the composite indexes we created
// Firestore will automatically select the best index based on the query filters
const docs = await firestoreService.getDocumentsByFields(COLLECTION, DB_ID, queryFilters);
// Calculate similarity scores and filter
const scoredResults = docs
.map(doc => {
const sim = similarity(prompt, doc.prompt);
return { doc, score: sim };
})
.filter(item => item.score < lastScore && item.score >= 0.85) // Lowered from 0.9
.sort((a, b) => b.score - a.score)
.slice(0, batchSize);
// Transform to expected format
return scoredResults.map(item => ({
data: item.doc.result,
score: item.score
}));
}
catch (error) {
console.error('[QueryCache.getCachedResultsBatch] Error:', error);
return [];
}
}
async getCachedResult(prompt, location) {
const now = Date.now();
console.log(`[Cache] ========== CACHE LOOKUP START ==========`);
console.log(`[Cache] Looking for cached result:`);
console.log(`[Cache] Query prompt: "${prompt}"`);
console.log(`[Cache] Query location:`, JSON.stringify(location));
console.log(`[Cache] Similarity threshold: 0.7`);
const matches = (entry) => {
const areaMatch = entry.location.area === location.area;
// If query doesn't specify region/country/timeline, match any cached value
const regionMatch = location.region === undefined || entry.location.region === location.region;
const countryMatch = location.country === undefined || entry.location.country === location.country;
const timelineMatch = location.timeline === undefined || entry.location.timeline === location.timeline;
// Check geographic parameters - more flexible matching
// If query has coords but entry doesn't, still allow match based on area
// If both have coords, they should match (checked later with geohash)
const hasQueryCoords = location.lat !== undefined && location.lng !== undefined;
const hasEntryCoords = entry.location.lat !== undefined && entry.location.lng !== undefined;
let geoMatch = true;
if (hasQueryCoords && hasEntryCoords) {
// Both have coordinates - use geohash for proximity matching
const queryGeohash = geohash.encode(location.lat, location.lng, 6);
const entryGeohash = geohash.encode(entry.location.lat, entry.location.lng, 6);
geoMatch = queryGeohash === entryGeohash;
}
// If only one has coordinates, we still allow matching based on area/region/country
if (entry.prompt.includes('family')) {
console.log(`[Cache] Found entry:`, entry);
console.log(`[Cache] Entry prompt: "${entry.prompt}"`);
console.log(`[Cache] Entry location:`, JSON.stringify(entry.location));
}
// Check buttonClickCount - both in location and options for backward compatibility
const entryButtonClick = entry.location.buttonClickCount || entry.options?.buttonClickCount;
const queryButtonClick = location.buttonClickCount;
const buttonClickMatch = entryButtonClick === queryButtonClick;
const ageCheck = now - entry.timestamp < DAY_MS;
const promptSimilarity = similarity(prompt, entry.prompt);
const similarityMatch = promptSimilarity >= 0.7; // Lowered for better recall
const allMatch = areaMatch && regionMatch && countryMatch && timelineMatch && buttonClickMatch &&
geoMatch && ageCheck && similarityMatch;
// Log detailed matching info for first few entries
console.log(`[Cache] Checking entry: "${entry.prompt.substring(0, 50)}..."`);
console.log(`[Cache] Entry location:`, JSON.stringify(entry.location));
console.log(`[Cache] Match checks: area=${areaMatch}, region=${regionMatch}, country=${countryMatch}, timeline=${timelineMatch}, buttonClick=${buttonClickMatch} (${queryButtonClick} vs ${entryButtonClick}), geo=${geoMatch} (query: ${hasQueryCoords ? 'has coords' : 'no coords'}, entry: ${hasEntryCoords ? 'has coords' : 'no coords'}), age=${ageCheck}, similarity=${promptSimilarity.toFixed(3)} (≥0.7?=${similarityMatch})`);
console.log(`[Cache] Overall match: ${allMatch}`);
return allMatch;
};
if (process.env.NODE_ENV === 'test' || process.env.OPENAI_STUB_TEST === '1') {
console.log(`[Cache] Using memory cache (${MEMORY_CACHE.length} entries)`);
for (const entry of MEMORY_CACHE) {
if (matches(entry))
return entry.result;
}
return null;
}
// Build Firestore query filters - simplified to avoid index issues
// Note: In query_cache collection, area is nested under location
const queryFilters = {
'location.area': location.area
};
// Note: Geographic parameters (lat/lng/radius) are not used for querying
// but are considered during the matching phase to ensure cached results
// match the geographic context of the request
// @ts-ignore
const { default: firestoreService } = await Promise.resolve().then(() => __importStar(require('../firestore/firestore')));
console.log(`[Cache] Querying Firestore with filters:`, JSON.stringify(queryFilters));
const startTime = Date.now();
// Add timeout protection to catch hanging queries
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Firestore query timeout after 10s')), 10000));
try {
const docs = await Promise.race([
firestoreService.getDocumentsByFields(COLLECTION, DB_ID, queryFilters),
timeoutPromise
]);
const queryTime = Date.now() - startTime;
console.log(`[Cache] Firestore query completed in ${queryTime}ms, returned ${docs.length} documents`);
if (docs.length === 0) {
console.log(`[Cache] No documents found in Firestore for location filters`);
return null;
}
// Client-side filtering for expired documents and other criteria
const now = Date.now();
const filteredDocs = docs.filter(doc => {
// Check if document has expire_at and if it's not expired
if (doc.expire_at) {
let expireTime;
if (typeof doc.expire_at === 'object' && 'toDate' in doc.expire_at) {
expireTime = doc.expire_at.toDate().getTime();
}
else if (typeof doc.expire_at === 'object' && '_seconds' in doc.expire_at) {
expireTime = doc.expire_at._seconds * 1000;
}
else if (typeof doc.expire_at === 'string') {
expireTime = new Date(doc.expire_at).getTime();
}
else {
expireTime = doc.expire_at;
}
if (expireTime <= now)
return false;
}
// Additional filtering - check both root and nested location fields
if (location.region !== undefined && (doc.location?.region || doc.region) !== location.region)
return false;
if (location.country !== undefined && (doc.location?.country || doc.country) !== location.country)
return false;
if (location.timeline !== undefined && (doc.location?.timeline || doc.timeline) !== location.timeline)
return false;
if (location.buttonClickCount !== undefined && doc.button_click_count !== location.buttonClickCount)
return false;
return true;
});
console.log(`[Cache] Filtered ${docs.length} down to ${filteredDocs.length} non-expired documents`);
if (filteredDocs.length === 0) {
console.log(`[Cache] No valid documents after filtering`);
return null;
}
console.log(`[Cache] Checking ${filteredDocs.length} cached entries for similarity match...`);
// Log all entries with full details for debugging
for (let i = 0; i < filteredDocs.length; i++) {
try {
const doc = filteredDocs[i];
// Handle Firestore Timestamp objects
let ts;
if (doc.timestamp && typeof doc.timestamp === 'object' && 'toDate' in doc.timestamp) {
// Firestore Timestamp object
ts = doc.timestamp.toDate().getTime();
}
else if (doc.timestamp && typeof doc.timestamp === 'object' && '_seconds' in doc.timestamp) {
// Firestore Timestamp in JSON format
ts = doc.timestamp._seconds * 1000;
}
else if (typeof doc.timestamp === 'string') {
// String timestamp
ts = new Date(doc.timestamp).getTime();
}
else if (typeof doc.timestamp === 'number') {
// Already a number (milliseconds)
ts = doc.timestamp;
}
else {
console.log(`[Cache] Warning: Invalid timestamp format for doc ${i + 1}, skipping:`, doc.timestamp);
continue;
}
// Only include defined properties in location
const locationObj = { area: doc.location?.area || doc.area };
if (doc.location?.region !== undefined || doc.region !== undefined)
locationObj.region = doc.location?.region || doc.region;
if (doc.location?.country !== undefined || doc.country !== undefined)
locationObj.country = doc.location?.country || doc.country;
if (doc.location?.timeline !== undefined || doc.timeline !== undefined)
locationObj.timeline = doc.location?.timeline || doc.timeline;
if (doc.location?.lat !== undefined || doc.lat !== undefined)
locationObj.lat = doc.location?.lat || doc.lat;
if (doc.location?.lng !== undefined || doc.lng !== undefined)
locationObj.lng = doc.location?.lng || doc.lng;
if (doc.location?.radius !== undefined || doc.radius !== undefined || doc.radius_km !== undefined) {
locationObj.radius = doc.location?.radius || doc.radius || doc.radius_km;
}
const entry = {
prompt: doc.prompt,
location: locationObj,
result: doc.result || doc.response, // Check for both 'result' and 'response' fields
timestamp: ts,
options: doc.button_click_count !== undefined ? { buttonClickCount: doc.button_click_count } : undefined
};
console.log(`[Cache] ===== Entry ${i + 1}/${docs.length} =====`);
console.log(`[Cache] Cached prompt: "${entry.prompt}"`);
console.log(`[Cache] Cached location:`, JSON.stringify(entry.location));
console.log(`[Cache] Cached timestamp: ${new Date(ts).toISOString()}`);
console.log(`[Cache] Result exists: ${!!entry.result}, Result type: ${typeof entry.result}, Has keys: ${entry.result ? Object.keys(entry.result).join(', ') : 'N/A'}`);
const areaMatch = entry.location.area === location.area;
// If query doesn't specify region/country/timeline, match any cached value
const regionMatch = location.region === undefined || entry.location.region === location.region;
const countryMatch = location.country === undefined || entry.location.country === location.country;
const timelineMatch = location.timeline === undefined || entry.location.timeline === location.timeline;
// Handle button click count matching - treat null/undefined as equal
const entryButtonClick = entry.options?.buttonClickCount;
const queryButtonClick = location.buttonClickCount;
const buttonClickMatch = (entryButtonClick === queryButtonClick) ||
(entryButtonClick == null && queryButtonClick == null);
// Geographic matching - if coordinates are provided, they should match
// We consider it a match if:
// 1. Neither has coordinates (both undefined)
// 2. Both have same coordinates and radius
const hasQueryCoords = location.lat !== undefined && location.lng !== undefined;
const hasEntryCoords = entry.location.lat !== undefined && entry.location.lng !== undefined;
let geoMatch = true;
// Only require geographic matching when BOTH have coordinates
if (hasQueryCoords && hasEntryCoords) {
// Both have coordinates - use geohash for proximity matching
// Use precision 6 (~1.2km) for neighborhood-level matching
const queryGeohash = geohash.encode(location.lat, location.lng, 6);
const entryGeohash = geohash.encode(entry.location.lat, entry.location.lng, 6);
// If geohashes match at precision 6, locations are within ~1.2km
geoMatch = queryGeohash === entryGeohash;
// Optional: For more flexibility, check if query location is within cached radius
if (!geoMatch && entry.location.radius) {
// Calculate actual distance using Haversine formula
const R = 6371; // Earth radius in km
const dLat = (entry.location.lat - location.lat) * Math.PI / 180;
const dLng = (entry.location.lng - location.lng) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(location.lat * Math.PI / 180) * Math.cos(entry.location.lat * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c; // Distance in km
// Match if query is within the cached entry's radius
geoMatch = distance <= entry.location.radius;
}
}
// If only one has coordinates, we still allow matching based on area/region/country
const ageCheck = now - entry.timestamp < DAY_MS;
const promptSimilarity = similarity(prompt, entry.prompt);
const similarityMatch = promptSimilarity >= 0.7; // Temporarily lowered for testing
console.log(`[Cache] Match analysis:`);
console.log(`[Cache] - Area match: ${areaMatch} (query: "${location.area}" vs cached: "${entry.location.area}")`);
console.log(`[Cache] - Region match: ${regionMatch} (query: "${location.region}" vs cached: "${entry.location.region}")`);
console.log(`[Cache] - Country match: ${countryMatch} (query: "${location.country}" vs cached: "${entry.location.country}")`);
console.log(`[Cache] - Timeline match: ${timelineMatch} (query: "${location.timeline}" vs cached: "${entry.location.timeline}")`);
console.log(`[Cache] - Button click match: ${buttonClickMatch} (query: "${queryButtonClick}" vs cached: "${entryButtonClick}")`);
console.log(`[Cache] - Geographic match: ${geoMatch} (query coords: ${hasQueryCoords ? `${location.lat},${location.lng}` : 'none'} vs cached: ${hasEntryCoords ? `${entry.location.lat},${entry.location.lng}` : 'none'})`);
console.log(`[Cache] - Age check: ${ageCheck} (age: ${((now - entry.timestamp) / 1000 / 60).toFixed(1)} minutes, limit: ${(DAY_MS / 1000 / 60).toFixed(0)} minutes)`);
console.log(`[Cache] - Prompt similarity: ${promptSimilarity.toFixed(4)} (≥0.7000? ${similarityMatch})`);
console.log(`[Cache] - Query prompt: "${prompt}"`);
console.log(`[Cache] - Cached prompt: "${entry.prompt}"`);
const allMatch = areaMatch && regionMatch && countryMatch && timelineMatch && buttonClickMatch && geoMatch && ageCheck && similarityMatch;
console.log(`[Cache] Overall match: ${allMatch ? '✅ YES' : '❌ NO'}`);
if (allMatch) {
console.log(`[Cache] 🎉 MATCH FOUND! Using cached entry from ${new Date(ts).toISOString()}`);
return entry.result;
}
console.log(`[Cache] ────────────────────────────────────────`);
}
catch (entryError) {
console.error(`[Cache] Error processing entry ${i + 1}/${docs.length}:`, entryError.message);
continue;
}
}
console.log(`[Cache] ❌ No matching entries found after checking all ${docs.length} documents`);
return null;
}
catch (error) {
const queryTime = Date.now() - startTime;
console.error(`[Cache] Firestore query failed after ${queryTime}ms:`, error.message);
return null;
}
}
async setCachedResult(prompt, location, result, options) {
console.log('[QueryCache] setCachedResult called with:', {
prompt: prompt.substring(0, 50) + '...',
location,
resultKeys: Object.keys(result || {}),
options
});
console.log('[QueryCache] ===== setCachedResult called =====');
console.log('[QueryCache] Environment check:');
console.log('[QueryCache] - NODE_ENV:', process.env.NODE_ENV);
console.log('[QueryCache] - OPENAI_STUB_TEST:', process.env.OPENAI_STUB_TEST);
console.log('[QueryCache] - DISABLE_CACHE_WRITER:', process.env.DISABLE_CACHE_WRITER);
console.log('[QueryCache] - GOOGLE_PROJECT_ID:', process.env.GOOGLE_PROJECT_ID || 'api-project-269146618053');
if (process.env.NODE_ENV === 'test' || process.env.OPENAI_STUB_TEST === '1') {
console.log('[QueryCache] 🧪 Test mode - using memory cache');
MEMORY_CACHE.push({ prompt, location, result, timestamp: Date.now(), options });
return;
}
console.log('[QueryCache] 🚀 Production mode - sending to Cloud Function');
console.log('[QueryCache] Request details:');
console.log('[QueryCache] - Prompt length:', prompt?.length || 0);
console.log('[QueryCache] - Prompt preview:', prompt?.substring(0, 50) + '...' || 'undefined');
console.log('[QueryCache] - Location:', JSON.stringify(location));
console.log('[QueryCache] - Result type:', typeof result);
console.log('[QueryCache] - Result keys:', result ? Object.keys(result) : 'undefined');
console.log('[QueryCache] - Options:', JSON.stringify(options));
// Fire-and-forget: Send to Cloud Function for async processing
try {
const cloudFunctionUrl = process.env.QUERY_CACHE_WRITER_URL ||
'https://write-query-cache-6yowtkjfaq-uc.a.run.app';
console.log('[QueryCache] - Cloud Function Full URL:', JSON.stringify(cloudFunctionUrl));
// Use fetch to call the Cloud Function asynchronously
const requestData = {
prompt,
location,
result,
options,
timestamp: new Date().toISOString()
};
console.log('[QueryCache] Request payload size:', JSON.stringify(requestData).length, 'bytes');
// Fire and forget - don't await the response
// For local development, you might want to skip this
if (process.env.DISABLE_CACHE_WRITER !== 'true') {
console.log('[QueryCache] 📤 Sending fire-and-forget request...');
fetch(cloudFunctionUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
}).then(response => {
console.log('[QueryCache] 📥 Response received - Status:', response.status);
if (!response.ok) {
console.error('[QueryCache] ❌ HTTP error:', response.status, response.statusText);
return response.text().then(text => {
console.error('[QueryCache] ❌ Response body:', text);
});
}
return response.json().then(data => {
console.log('[QueryCache] ✅ Success response:', JSON.stringify(data));
});
}).catch(error => {
// Log error but don't block the user
console.error('[QueryCache] ❌ Fire-and-forget cache write failed:');
console.error('[QueryCache] - Error type:', error.constructor.name);
console.error('[QueryCache] - Error message:', error.message);
console.error('[QueryCache] - Error stack:', error.stack);
});
}
else {
console.log('[QueryCache] ⏸️ Cache writer disabled (DISABLE_CACHE_WRITER=true)');
}
console.log('[QueryCache] ✅ Cache write initiated successfully');
}
catch (error) {
// Even if the fire-and-forget fails, don't block the user
console.error('[QueryCache] ❌ Failed to initiate cache write:');
console.error('[QueryCache] - Error type:', error.constructor.name);
console.error('[QueryCache] - Error message:', error.message);
console.error('[QueryCache] - Error stack:', error.stack);
}
}
/**
* Generate a short hash of a string for ID generation
*/
hashString(str) {
let hash = 0;
if (str.length === 0)
return hash.toString();
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash).toString(36).substring(0, 8); // Convert to base36 and take first 8 chars
}
}
exports.default = QueryCache;
//# sourceMappingURL=query-cache.js.map