UNPKG

pulse-ai-utils

Version:

Utility functions and helpers for AI-powered applications

462 lines 27.8 kB
"use strict"; 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