UNPKG

pulse-ai-utils

Version:

Utility functions and helpers for AI-powered applications

1,107 lines (1,102 loc) 51 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; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.LLMBase = void 0; const zod_1 = require("openai/helpers/zod"); const zod_2 = require("zod"); const axios_1 = __importDefault(require("axios")); const query_cache_1 = __importDefault(require("./query-cache")); const pulseSchemas_1 = require("../utils/pulseSchemas"); const content_types_config_1 = require("../config/content-types-config"); const vector_search_1 = require("../supabase/vector-search"); class LLMBase { constructor(config, openaiInstance, cache) { if (openaiInstance) { this.openai = openaiInstance; this.defaultModel = config.model; this.cache = cache || new query_cache_1.default(); return; } this.defaultModel = config.model; this.cache = cache || new query_cache_1.default(); this.openai = this.createOpenAIInstance(config); } getApiKey() { if (this.isTestMode()) { console.warn('Using dummy API key for tests'); return 'dummy-key-for-tests'; } throw new Error('API key is required'); } isTestMode() { return (process.env.NODE_ENV === 'test' || process.env.OPENAI_STUB_TEST === '1' || process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true' || this.openai?.apiKey === 'dummy-key-for-tests'); } // Getters for testing purposes get model() { return this.defaultModel; } get client() { return this.openai; } // Common parsing and utility methods parseModelResponse(response, zodSchema) { return zodSchema.parse(response.output_parsed); } async enhanceWithImages(items, responseFormatName) { if (!Array.isArray(items)) return; await Promise.all(items.map(async (item) => { if ((!item.image_url || item.image_url === '') || (!item.thumbnail_url || item.thumbnail_url === '')) { try { if (item.source_url) { const resp = await axios_1.default.get('https://api.microlink.io', { params: { url: item.source_url } }); const microlinkImage = resp.data?.data?.image?.url; if (microlinkImage && !microlinkImage.includes("login")) { if (!item.thumbnail_url || item.thumbnail_url === '') { item.thumbnail_url = microlinkImage; } if (!item.image_url || item.image_url === '') { item.image_url = microlinkImage; } } } } catch (err) { // Optionally log error } } // Remove thumbnails for non-reel content types if (item.category !== 'reels') { item.thumbnail_url = ''; } })); } async fetchStructuredDataFromWeb({ model, prompt, recommendedSources = [], zodSchema, userLocation, locationGranularity, systemPrompt, timeline, responseFormatName = 'structured_response', customFormat, options = {} }) { const cacheLoc = { area: locationGranularity, region: userLocation?.region, country: userLocation?.country, timeline, buttonClickCount: options.buttonClickCount ? String(options.buttonClickCount) : undefined }; if (this.isTestMode()) { return { [responseFormatName]: [] }; } const today = new Date(); const yyyy = today.getFullYear(); const mm = String(today.getMonth() + 1).padStart(2, '0'); const dd = String(today.getDate()).padStart(2, '0'); const todayDate = `${yyyy}-${mm}-${dd}`; if (!systemPrompt || typeof systemPrompt !== 'string' || systemPrompt.trim() === '') { systemPrompt = 'You are a helpful assistant.'; } const userPrompt = `Today's date is ${todayDate}. ${prompt}; all results should be for ${locationGranularity} area. We are local app. Ensure that they are relevant and up-to-date.`; const payload = { model: model || this.defaultModel, tools: [ { type: 'web_search', user_location: userLocation, }, ], input: [ { role: 'system', content: [ { type: 'input_text', text: systemPrompt, }, ], }, { role: 'user', content: [ { type: 'input_text', text: userPrompt, }, ], }, ], }; if (customFormat) { payload.text = { format: customFormat(zodSchema, responseFormatName) }; } else { payload.text = { format: (0, zod_1.zodTextFormat)(zodSchema, responseFormatName) }; } const response = await this.openai.responses.parse(payload); let parsed = zodSchema.parse(response.output_parsed); // Fill missing image_url or thumbnail_url using Microlink if (parsed && Array.isArray(parsed[responseFormatName])) { await this.enhanceWithImages(parsed[responseFormatName], responseFormatName); } // Save to Firestore cache (fire-and-forget, queue will handle RAG processing) // Don't await this - it's fire-and-forget this.cache.setCachedResult(prompt, cacheLoc, parsed, { model: model || this.defaultModel, provider: this.getProviderName(), ...options }).catch(error => { // Log but don't fail the request console.error('[LLMBase] Failed to cache result (non-blocking):', error.message); }); // NOTE: Individual content items should NOT be saved to Firestore here // Web search results are cached in llmCache only for fast retrieval // Proper content ingestion flow: n8n workflows → Firestore content → Cloud Functions → Supabase // This prevents expensive duplicate writes and maintains proper ingestion pipeline return parsed; } async fetchStructuredData({ model, prompt, html, zodSchema, responseFormatName = 'structured_response', }) { if (this.isTestMode()) { return { [responseFormatName]: [] }; } const response = await this.openai.responses.parse({ model: model || this.defaultModel, input: [ { role: 'system', content: [ { type: 'input_text', text: html, }, ], }, { role: 'user', content: [ { type: 'input_text', text: prompt, }, ], }, ], text: { format: (0, zod_1.zodTextFormat)(zodSchema, responseFormatName) }, }); return zodSchema.parse(response.output_parsed); } async runQuery({ prompt, categories, systemPrompt, model, area, source, country, region, category, timeline, strategy = 'web_search', options = {} }) { if (!prompt) { if (category && timeline && source) { prompt = `Let me know any ${category} happening ${timeline} from ${source}`; } else { throw new Error('Missing prompt'); } } if (!area) { throw new Error('Missing area'); } // If category is provided, use it; otherwise use categories or default const categoryList = category ? [category] : (Array.isArray(categories) && categories.length > 0 ? categories : (0, content_types_config_1.getDefaultContentTypes)()); let schemas = []; try { schemas = categoryList.map(c => (0, pulseSchemas_1.getSchemaByCategory)(String(c)).zod); } catch (e) { throw new Error('Invalid category'); } const shapes = schemas.map(s => s.shape); const fieldCounts = {}; const unionShape = {}; const fieldTypes = {}; for (const shape of shapes) { for (const [key, value] of Object.entries(shape)) { fieldCounts[key] = (fieldCounts[key] || 0) + 1; if (!fieldTypes[key]) fieldTypes[key] = []; fieldTypes[key].push(value); } } const totalSchemas = shapes.length; for (const key of Object.keys(fieldTypes)) { const uniqueTypes = []; const seen = new Set(); for (const t of fieldTypes[key]) { const sig = t.toString(); if (!seen.has(sig)) { uniqueTypes.push(t); seen.add(sig); } } let merged; if (uniqueTypes.length === 1) { merged = uniqueTypes[0]; } else { merged = uniqueTypes.reduce((a, b) => a.or(b)); } if (fieldCounts[key] !== totalSchemas) { const optionalMerged = zod_2.z.optional(merged); unionShape[key] = optionalMerged; } else { unionShape[key] = merged; } } // Create dynamic enum based on enabled content types const enabledTypes = (0, content_types_config_1.getEnabledContentTypes)(); unionShape['category'] = zod_2.z.enum(enabledTypes); const ItemSchema = zod_2.z.object(unionShape); const ResponseSchema = zod_2.z.object({ data: zod_2.z.array(ItemSchema) }); // Strategy switch case - determines data source switch (strategy) { case 'rag_vector': { // Use vector search with union schema formatting const vectorResults = await this.searchContentVectors(prompt, { limit: 10, filters: category ? { type: category } : (categoryList.length === 1 ? { type: categoryList[0] } : undefined) }); // Format results according to union schema const formattedData = vectorResults.map(result => ({ ...result.data, category: result.type || (0, content_types_config_1.getDefaultContentType)(), id: result.id, similarity: result.similarity })); return { data: formattedData }; } case 'rag_cache': { // Use Supabase LLM cache with vector similarity (not Firestore cache) const cacheLocation = { area: String(area), region: region ? String(region) : undefined, country: country ? String(country) : undefined, timeline: timeline ? String(timeline) : undefined }; try { // Import Supabase query cache dynamically to avoid circular dependencies const { SupabaseQueryCache } = await Promise.resolve().then(() => __importStar(require('../supabase/query-cache-supabase'))); const supabaseCache = new SupabaseQueryCache(); const cachedResult = await supabaseCache.getCachedResult(prompt, cacheLocation); if (cachedResult) { console.log('[RagCache] Found cached result in Supabase query cache with vector similarity'); return cachedResult; } } catch (error) { console.warn('[RagCache] Error accessing Supabase query cache:', error); } // If no cache hit, return empty results return { data: [] }; } case 'rag_hybrid': { // Use hybrid search (vector + full-text) with union schema formatting const hybridResults = await this.hybridContentSearch(prompt, { limit: 10, filters: category ? { type: category } : (categoryList.length === 1 ? { type: categoryList[0] } : undefined) }); // Format results according to union schema const formattedData = hybridResults.map(result => ({ ...result.data, category: result.type || (0, content_types_config_1.getDefaultContentType)(), id: result.id, similarity: result.similarity })); return { data: formattedData }; } case 'query_cache': { // Use only Firestore cache (Levenshtein similarity) const cacheLocation = { area: String(area), region: region ? String(region) : undefined, country: country ? String(country) : undefined, timeline: timeline ? String(timeline) : undefined, buttonClickCount: options.buttonClickCount ? String(options.buttonClickCount) : undefined }; console.log(`[CacheOnly] Starting cache lookup for prompt: "${prompt.substring(0, 50)}..." in area: ${area}`); const cacheStartTime = Date.now(); const cachedResult = await this.cache.getCachedResult(prompt, cacheLocation); const cacheEndTime = Date.now(); if (cachedResult) { console.log(`[CacheOnly] Cache HIT found in ${cacheEndTime - cacheStartTime}ms`); // Ensure the cached result has the expected structure // Check if it already has a 'data' property if (cachedResult.data !== undefined) { console.log(`[CacheOnly] Cached result has 'data' property with ${Array.isArray(cachedResult.data) ? cachedResult.data.length : 'non-array'} items`); return cachedResult; } // If the cached result is an array, wrap it in a data property if (Array.isArray(cachedResult)) { console.log(`[CacheOnly] Cached result is an array with ${cachedResult.length} items, wrapping in data property`); return { data: cachedResult }; } // If it's an object but doesn't have data property, check for common response format names if (typeof cachedResult === 'object' && cachedResult !== null) { // Check for other common property names that might contain the data const possibleDataKeys = ['results', 'items', 'response', 'content']; for (const key of possibleDataKeys) { if (Array.isArray(cachedResult[key])) { console.log(`[CacheOnly] Found array data in '${key}' property with ${cachedResult[key].length} items`); return { data: cachedResult[key] }; } } // If we have a response format like { events: [...], deals: [...] }, collect all arrays const arrays = Object.values(cachedResult).filter(val => Array.isArray(val)); if (arrays.length > 0) { const flatData = arrays.flat(); console.log(`[CacheOnly] Found ${arrays.length} array properties, flattened to ${flatData.length} total items`); return { data: flatData }; } } // If we can't determine the structure, log a warning and return as-is console.warn(`[CacheOnly] Cached result has unexpected structure:`, JSON.stringify(cachedResult).substring(0, 200)); return cachedResult; } console.log(`[CacheOnly] Cache MISS after ${cacheEndTime - cacheStartTime}ms - returning empty results`); // No cache hit, return empty results return { data: [] }; } case 'vector_only': { // Use only vector search without fallbacks const vectorResults = await this.searchContentVectors(prompt, { limit: 10, filters: category ? { type: category } : (categoryList.length === 1 ? { type: categoryList[0] } : undefined) }); // Format results according to union schema const formattedData = vectorResults.map(result => ({ ...result.data, category: result.type || (0, content_types_config_1.getDefaultContentType)(), id: result.id, similarity: result.similarity })); return { data: formattedData }; } case 'hybrid_only': { // Use only hybrid search without fallbacks const hybridResults = await this.hybridContentSearch(prompt, { limit: 10, filters: category ? { type: category } : (categoryList.length === 1 ? { type: categoryList[0] } : undefined) }); // Format results according to union schema const formattedData = hybridResults.map(result => ({ ...result.data, category: result.type || (0, content_types_config_1.getDefaultContentType)(), id: result.id, similarity: result.similarity })); return { data: formattedData }; } case 'web_search': default: { // Check if provider supports OpenAI Responses API (web search with structured output) const supportsResponsesAPI = this.getProviderName() === 'openai'; if (supportsResponsesAPI) { // Original web search logic with union schema (OpenAI only) const userLocation = { type: 'approximate', country: country ? String(country) : 'US', region: region ? String(region) : 'FL', city: String(area), }; const defaultSystem = 'Provide local content in JSON format.'; const finalSystem = systemPrompt ? `${defaultSystem} ${systemPrompt}` : defaultSystem; const result = await this.fetchStructuredDataFromWeb({ model, prompt, recommendedSources: source ? [String(source)] : [], zodSchema: ResponseSchema, userLocation, locationGranularity: String(area), systemPrompt: finalSystem, timeline: timeline ? String(timeline) : undefined, responseFormatName: 'data', options }); return result; } else { // Fallback for non-OpenAI providers: use general chat completion // Since other providers don't support web search, return empty results console.warn(`[WebSearch] Provider '${this.getProviderName()}' does not support web search. Use OpenAI for web search functionality.`); return { data: [] }; } } } } /** * Search content using vector similarity from Supabase * This replaces Pinecone functionality with Supabase pgvector */ async searchContentVectors(query, options) { try { const results = await (0, vector_search_1.searchContent)({ query, ...options }); return results; } catch (error) { console.error('Error in searchContentVectors:', error); throw error; } } /** * Search content chunks for longer documents */ async searchChunks(query, limit = 20, threshold = 0.7) { try { const results = await (0, vector_search_1.searchContentChunks)(query, limit, threshold); return results; } catch (error) { console.error('Error in searchChunks:', error); throw error; } } /** * Hybrid search combining vector and full-text search */ async hybridContentSearch(query, options) { try { const results = await (0, vector_search_1.hybridSearch)(query, options); return results; } catch (error) { console.error('Error in hybridContentSearch:', error); throw error; } } /** * Generate embedding for a given text * Used for custom vector operations */ async generateEmbedding(text) { throw new Error('generateEmbedding must be implemented in subclass'); } /** * Search and format results based on categories */ async searchAndFormat(query, categories, area, limit = 10) { try { // If no categories specified, search without category filter if (!categories || categories.length === 0) { return this.searchContentVectors(query, { limit }); } // Search across all requested categories const searchPromises = categories.map(category => this.searchContentVectors(query, { limit, filters: { type: category } })); const allResults = await Promise.all(searchPromises); const flatResults = allResults.flat(); // Sort by similarity score flatResults.sort((a, b) => b.similarity - a.similarity); // Format results according to category schemas const formattedResults = flatResults.slice(0, limit).map(result => { const category = result.type; const schemaInfo = (0, pulseSchemas_1.getSchemaByCategory)(category); // Extract data according to schema return { ...result.data, category, similarity: result.similarity, id: result.id }; }); return { data: formattedResults, total: formattedResults.length }; } catch (error) { console.error('Error in searchAndFormat:', error); throw error; } } /** * Query with RAG (Retrieval Augmented Generation) * Combines vector search context with LLM generation */ async queryWithContext({ query, systemPrompt, categories = (0, content_types_config_1.getEnabledContentTypes)(), searchOptions, model, useHybridSearch = true, contextLimit = 5 }) { try { // Step 1: Retrieve relevant context using vector/hybrid search let contextResults; if (useHybridSearch) { contextResults = await this.hybridContentSearch(query, { limit: contextLimit, filters: { ...searchOptions?.filters, type: categories.length === 1 ? categories[0] : undefined } }); } else { contextResults = await this.searchContentVectors(query, { limit: contextLimit, filters: { ...searchOptions?.filters, type: categories.length === 1 ? categories[0] : undefined } }); } // Step 2: Build context string from search results const contextString = contextResults.map((result, idx) => { return `[${idx + 1}] Title: ${result.title || 'Untitled'} Type: ${result.type} Source: ${result.source || 'Unknown'} Content: ${result.data?.description || result.data?.content || 'No description'} ---`; }).join('\n\n'); // Step 3: Create enhanced prompt with context const enhancedSystemPrompt = systemPrompt || `You are a helpful assistant with access to a database of local events, deals, news, and reels.`; const contextPrompt = `Based on the following relevant information from our database: ${contextString} User Query: ${query} Please provide a helpful response that incorporates the relevant information above. Be specific and reference the sources when appropriate.`; // Step 4: Generate response using LLM const completion = await this.openai.chat.completions.create({ model: model || this.defaultModel, messages: [ { role: 'system', content: enhancedSystemPrompt }, { role: 'user', content: contextPrompt } ], temperature: 0.7, max_tokens: 1000 }); const response = completion.choices[0]?.message?.content || 'No response generated'; return { response, context: contextResults }; } catch (error) { console.error('Error in queryWithContext:', error); throw error; } } /** * Generate embeddings and search in one call (convenience method) */ async semanticSearch(query, options) { const { categories = (0, content_types_config_1.getEnabledContentTypes)(), limit = 10, threshold = 0.7, useCache = true } = options || {}; try { // Check cache first if enabled if (useCache) { const location = { area: `semantic_search:${categories.join(',')}:${limit}`, region: undefined, country: undefined, timeline: undefined }; const cached = await this.cache.getCachedResult(query, location); if (cached) { return cached; } } // Perform vector search const results = await this.searchContentVectors(query, { limit, threshold, filters: categories.length === 1 ? { type: categories[0] } : undefined }); // Cache results if enabled (fire-and-forget) if (useCache && results.length > 0) { const location = { area: `semantic_search:${categories.join(',')}:${limit}`, region: undefined, country: undefined, timeline: undefined }; // Don't await - fire-and-forget this.cache.setCachedResult(query, location, results, { model: this.defaultModel, provider: this.getProviderName() }).catch(error => { // Log but don't fail the request console.error('[LLMBase] Failed to cache semantic search result (non-blocking):', error.message); }); } return results; } catch (error) { console.error('Error in semanticSearch:', error); throw error; } } /** * Live web search with LLM processing (separate from RAG) * This method is designed to be called independently for real-time web data */ async liveWebSearch({ query, categories = (0, content_types_config_1.getEnabledContentTypes)(), area, region, country, timeline, zodSchema, responseFormatName = 'web_search_results', model, systemPrompt }) { const startTime = Date.now(); try { // Build location-aware search query let enhancedQuery = query; if (area) { enhancedQuery = `${query} in ${area}`; if (region) enhancedQuery += `, ${region}`; if (country) enhancedQuery += `, ${country}`; } if (timeline) { enhancedQuery += ` ${timeline}`; } console.log(`[LiveWebSearch] Enhanced query: "${enhancedQuery}"`); // If zodSchema is provided, use structured data extraction if (zodSchema) { const structuredPrompt = systemPrompt || `Search the web for ${categories.join(', ')} and extract relevant information. Focus on current, accurate information for the specified location.`; // Note: fetchStructuredDataFromWeb requires HTML content, not a search query // For now, we'll use a basic LLM call. In a real implementation, // you'd want to first perform web search, then process the HTML const completion = await this.openai.chat.completions.create({ model: model || this.defaultModel, messages: [ { role: 'system', content: structuredPrompt }, { role: 'user', content: `Search and structure information about: ${enhancedQuery}` } ], temperature: 0.3, max_tokens: 1500 }); const result = completion.choices[0]?.message?.content || 'No results found'; return { data: result, // Cast for compatibility with generic type source: 'web_search', executionTime: Date.now() - startTime, metadata: { searchQuery: enhancedQuery, area, categories } }; } // Otherwise, use general web search const searchPrompt = systemPrompt || `Search the web for current information about: ${enhancedQuery} Please find relevant ${categories.join(', ')} and provide a structured response with: - Title - Description - Source URL - Date (if available) - Location relevance Focus on current, accurate information.`; const completion = await this.openai.chat.completions.create({ model: model || this.defaultModel, messages: [ { role: 'system', content: 'You are a helpful assistant that searches the web for current information and provides structured responses.' }, { role: 'user', content: searchPrompt } ], temperature: 0.3, max_tokens: 1500 }); const response = completion.choices[0]?.message?.content || 'No results found'; return { data: { response, query: enhancedQuery }, // Cast to any for flexibility source: 'web_search', executionTime: Date.now() - startTime, metadata: { searchQuery: enhancedQuery, area, categories } }; } catch (error) { console.error('Error in liveWebSearch:', error); throw error; } } /** * Execute waterfall strategy with automatic fallback and rag_level ceiling */ async executeWaterfallStrategy({ strategy, prompt, area, region, country, timeline, category, enableFallbacks = true, maxFallbacks = 10, systemPrompt, options = {} }) { const startTime = Date.now(); // Strategy level order for ceiling enforcement const STRATEGY_LEVELS = { 'query_cache': 0, 'rag_cache': 1, 'rag_vector': 2, 'rag_hybrid': 3, 'web_search_llm': 4 }; // Waterfall configuration with fallback chains const WATERFALL_CONFIG = { query_cache: { primary: 'query_cache', fallbacks: ['rag_cache', 'vector_only', 'hybrid_only', 'web_search'], timeout: 15000, description: 'Firestore Levenshtein string similarity (~50ms)' }, rag_cache: { primary: 'rag_cache', fallbacks: ['vector_only', 'hybrid_only', 'web_search'], timeout: 30000, description: 'Supabase LLM cache semantic similarity (~100ms)' }, rag_vector: { primary: 'vector_only', fallbacks: ['hybrid_only', 'web_search'], timeout: 30000, description: 'Supabase vector search only (~200ms)' }, rag_hybrid: { primary: 'hybrid_only', fallbacks: ['web_search'], timeout: 30000, description: 'Supabase vector + full-text search (~400ms)' }, web_search_llm: { primary: 'web_search', fallbacks: [], timeout: 30000, description: 'Live web search with LLM processing (~2-10s)' } }; // Apply rag_level ceiling - elevate strategy if needed const ragLevel = await this.getRagLevel(); const requestedLevel = STRATEGY_LEVELS[strategy]; const maxAllowedLevel = STRATEGY_LEVELS[ragLevel]; // If requested level exceeds allowed ceiling, elevate to ceiling let actualStrategy = strategy; let originalStrategy; if (requestedLevel > maxAllowedLevel) { originalStrategy = strategy; actualStrategy = ragLevel; console.log(`[RagLevel] Strategy elevated from ${strategy} to ${actualStrategy} due to rag_level ceiling`); } const config = WATERFALL_CONFIG[actualStrategy]; const fallbackChain = []; // Track attempted strategies to prevent infinite loops const attemptedStrategies = new Set(); // Infer timeline from query if not provided const inferredTimeline = timeline || this.inferTimelineFromQuery(prompt); let primaryTime = 0; let fallbackTime = 0; let result = null; let fallbackUsed; try { // Try primary strategy console.log(`[WaterfallStrategy] Executing primary strategy: ${actualStrategy} -> ${config.primary}`); const primaryStart = Date.now(); fallbackChain.push(config.primary); attemptedStrategies.add(config.primary); result = await Promise.race([ this.runQuery({ prompt, area: area || 'tampa-bay', region, country: country || 'US', timeline: inferredTimeline, strategy: config.primary, category, systemPrompt, options // Pass options through }), new Promise((_, reject) => setTimeout(() => reject(new Error('Primary strategy timeout')), config.timeout)) ]); primaryTime = Date.now() - primaryStart; // Check if we got meaningful results (must have data and length > 0) if (result?.data && Array.isArray(result.data) && result.data.length > 0) { console.log(`[WaterfallStrategy] Primary strategy ${config.primary} succeeded with ${result.data.length} results`); return { success: true, data: result.data, source: actualStrategy, strategy: actualStrategy, originalStrategy, fallbackChain, timing: { primary_ms: primaryTime, fallback_ms: 0, total_ms: Date.now() - startTime }, meta: { original_count: result.data.length, flyer_count: result.data.filter((item) => item._type === 'flyer').length, total_items: result.data.length, cache_hit: actualStrategy === 'query_cache' || actualStrategy === 'rag_cache' }, timestamp: new Date().toISOString() }; } // Primary strategy returned empty data or failed - log and continue to fallbacks if (result?.data && Array.isArray(result.data) && result.data.length === 0) { console.log(`[WaterfallStrategy] Primary strategy ${config.primary} returned empty results, attempting fallbacks`); } else { console.log(`[WaterfallStrategy] Primary strategy ${config.primary} returned no/invalid data, attempting fallbacks`); } } catch (error) { console.warn(`[WaterfallStrategy] Primary strategy ${config.primary} failed:`, error instanceof Error ? error.message : error); } // Execute fallback chain if enabled and fallbacks exist if (enableFallbacks && config.fallbacks.length > 0) { const fallbackStart = Date.now(); const fallbacksToTry = config.fallbacks.slice(0, maxFallbacks); for (const fallbackStrategy of fallbacksToTry) { // Prevent infinite loops - skip if we've already tried this strategy if (attemptedStrategies.has(fallbackStrategy)) { console.log(`[WaterfallStrategy] Skipping ${fallbackStrategy} - already attempted (loop prevention)`); continue; } try { console.log(`[WaterfallStrategy] Trying fallback: ${fallbackStrategy}`); fallbackChain.push(fallbackStrategy); attemptedStrategies.add(fallbackStrategy); const fallbackResult = await Promise.race([ this.runQuery({ prompt, area: area || 'tampa-bay', region, country: country || 'US', timeline: inferredTimeline, strategy: fallbackStrategy, category, systemPrompt, options // Pass options through }), new Promise((_, reject) => { const fallbackConfig = Object.values(WATERFALL_CONFIG).find(c => c.primary === fallbackStrategy); const timeout = fallbackConfig?.timeout || 5000; setTimeout(() => reject(new Error('Fallback timeout')), timeout); }) ]); // Check for meaningful results (data must exist and have length > 0) if (fallbackResult?.data && Array.isArray(fallbackResult.data) && fallbackResult.data.length > 0) { console.log(`[WaterfallStrategy] Fallback ${fallbackStrategy} succeeded with ${fallbackResult.data.length} results`); fallbackUsed = fallbackStrategy; result = fallbackResult; break; } else { // Log empty results and continue to next fallback if (fallbackResult?.data && Array.isArray(fallbackResult.data) && fallbackResult.data.length === 0) { console.log(`[WaterfallStrategy] Fallback ${fallbackStrategy} returned empty results, trying next fallback`); } else { console.log(`[WaterfallStrategy] Fallback ${fallbackStrategy} returned no/invalid data, trying next fallback`); } } } catch (error) { console.warn(`[WaterfallStrategy] Fallback ${fallbackStrategy} failed:`, error instanceof Error ? error.message : error); continue; } } fallbackTime = Date.now() - fallbackStart; } // Process final results if (result?.data && Array.isArray(result.data) && result.data.length > 0) { console.log(`[WaterfallStrategy] Success! Attempted: [${Array.from(attemptedStrategies).join(' → ')}], Found: ${result.data.length} results`); return { success: true, data: result.data, source: actualStrategy, strategy: actualStrategy, originalStrategy, fallbackUsed, fallbackChain, timing: { primary_ms: primaryTime, fallback_ms: fallbackTime, total_ms: Date.now() - startTime }, meta: { original_count: result.data.length, flyer_count: result.data.filter((item) => item._type === 'flyer').length, total_items: result.data.length, cache_hit: fallbackUsed === 'rag_cache' || actualStrategy === 'query_cache' || actualStrategy === 'rag_cache' }, timestamp: new Date().toISOString() }; } // No results found in entire waterfall console.log(`[WaterfallStrategy] No results found after attempting: [${Array.from(attemptedStrategies).join(' → ')}]`); return { success: false, data: [], source: actualStrategy, strategy: actualStrategy, originalStrategy, fallbackUsed, fallbackChain, timing: { primary_ms: primaryTime, fallback_ms: fallbackTime, total_ms: Date.now() - startTime }, meta: { original_count: 0, flyer_count: 0, total_items: 0, cache_hit: false }, timestamp: new Date().toISOString() }; } /** * Get RAG level ceiling from remote config */ async getRagLevel() { try { // Import dynamically to avoid circular dependencies const { getRagLevel } = await Promise.resolve().then(() => __importStar(require('../config/firebase-config'))); return await getRagLevel(); } catch (error) { console.warn('Failed to get rag_level, using default:', error); return 'web_search_llm'; // Default to highest level (no restrictions) } } /** * Infer timeline from query text */ inferTimelineFromQuery(query) { const lower = query.toLowerCase(); if (lower.includes('today')) return 'today'; if (lower.includes('this week')) return 'this week'; if (lower.includes('weekend')) return 'this weekend'; if (lower.includes('month')) return 'this month'; return undefined; } /** * Enhanced parallel execution using waterfall strategies * Returns fastest cache + web search results in parallel */ async executeParallelSearch({ prompt, area, region, country, timeline, category, enableFallbacks = true, systemPrompt }) { const startTime = Date.now(); console.log('[ParallelSearch] Starting parallel execution: fastest cache + web search'); // Execute searches in parallel - use fastest cache level + web search const promises = [ // Try fastest available cache strategy (respects rag_level ceiling) this.executeWaterfallStrategy({ strategy: 'query_cache', prompt, area, region, country, timeline, category, enableFallbacks, systemPrompt }).catch(e => ({ success: false, error: e.message, source: 'query_cache', data: [] })), // Always try web search for fresh data this.executeWaterfallStrategy({ strategy: 'web_search_llm', prompt, area, region, country, timeline, category, enableFallbacks: false, // Web search is terminal, no fallbacks needed systemPrompt }).catch(e => ({ success: false, error: e.message, source: 'web_search_llm', data: [] })) ]; const [cacheResult, webResult] = await Promise.all(promises); return { success: true, cache: { success: cacheResult.success || false, data: cacheResult.data || [], source: cacheResult.source || 'none', strategy: cacheResult.strategy || 'none', originalStrategy: cacheResult.originalStrategy, fallbackUsed: cacheResult.fallbackUsed, fallbackChain: cacheResult.fallbackChain || [], timing: cacheResult.timing || {}, meta: cacheResult.meta || {}, error: cacheResult.error }, webSearch: { success: webResult.success || false, data: webResult.data || [], source: webResult.source || 'none', strategy: webResult.strategy || 'none', originalStrategy: webResult.originalStrategy, timing: webResult.timing || {}, meta: webResult.meta || {}, error: webResult.error }, timing: { total_ms: Date.now() - startTime, cache_completed: true, web_completed: true }, timestamp: new Date().toISOString() }; } /** * Legacy parallel execution (kept for backwards compatibility) * Returns results as they become available for better UX */ async parallelSearch({ query, area, region, country, timeline, categories = (0, content_types_config_1.getEnabledContentTypes)(), zodSchema, includeWebSearch = true, model }) { const startTime = Date.now(); // Start RAG search using waterfall strategy const ragPromise = this.executeWaterfallStrategy({ strategy: 'rag_vector', prompt: query, area, region, country, timeline }); // Start web search in parallel if requested const webSearchPromise = includeWebSearch ? this.liveWebSearch({ query, area, region, country, timeline, categories, zodSchema, model }) : Pr