pulse-ai-utils
Version:
Utility functions and helpers for AI-powered applications
1,107 lines (1,102 loc) • 51 kB
JavaScript
"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