UNPKG

pulse-ai-utils

Version:

Utility functions and helpers for AI-powered applications

1,119 lines 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 axios_1 = __importDefault(require("axios")); const query_cache_1 = __importDefault(require("./query-cache")); const content_types_config_1 = require("../config/content-types-config"); const buildUnionSchema_1 = require("../utils/buildUnionSchema"); const vector_search_1 = require("../supabase/vector-search"); /** * Attempts to salvage partial JSON data by extracting complete objects * from a potentially truncated JSON string */ function salvagePartialJSON(rawOutput, responseFormatName) { try { // First try normal parsing return JSON.parse(rawOutput); } catch (parseError) { console.warn('[LLMBase] Initial JSON parse failed, attempting to salvage partial data'); // Look for the response format key const keyPattern = new RegExp(`"${responseFormatName}"\\s*:\\s*\\[`); const keyMatch = rawOutput.match(keyPattern); if (!keyMatch) { console.error('[LLMBase] Could not find response format key in output'); return { [responseFormatName]: [] }; } const startIndex = keyMatch.index + keyMatch[0].length; // Extract the array portion const arrayPortion = rawOutput.substring(startIndex); // Find complete objects by tracking braces const completeObjects = []; let currentObject = ''; let braceCount = 0; let inString = false; let escapeNext = false; for (let i = 0; i < arrayPortion.length; i++) { const char = arrayPortion[i]; // Handle escape sequences if (escapeNext) { currentObject += char; escapeNext = false; continue; } if (char === '\\') { escapeNext = true; currentObject += char; continue; } // Track string boundaries if (char === '"' && !escapeNext) { inString = !inString; } // Track brace depth outside of strings if (!inString) { if (char === '{') { if (braceCount === 0) { currentObject = ''; // Start new object } braceCount++; } else if (char === '}') { braceCount--; if (braceCount === 0) { // Complete object found currentObject += char; try { const obj = JSON.parse(currentObject); completeObjects.push(obj); currentObject = ''; } catch (objParseError) { console.warn('[LLMBase] Failed to parse individual object:', currentObject.substring(0, 100)); } continue; } } else if (char === ']' && braceCount === 0) { // End of array break; } } if (braceCount > 0) { currentObject += char; } } console.log(`[LLMBase] Salvaged ${completeObjects.length} complete objects from partial JSON`); return { [responseFormatName]: completeObjects }; } } class LLMBase { // Area hierarchy service removed - using geographic search instead constructor(config, openaiInstance, cache) { if (openaiInstance) { this.openai = openaiInstance; this.defaultModel = config.model; this.cache = cache || new query_cache_1.default(); // Area hierarchy service initialization removed return; } this.defaultModel = config.model; this.cache = cache || new query_cache_1.default(); this.openai = this.createOpenAIInstance(config); // Area hierarchy service initialization removed } 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 = ''; } })); } /** * Fetches structured data from web search using streaming * Uses chat completions API with streaming for better handling of large responses */ async fetchStructuredDataFromWebStream({ model, prompt, recommendedSources = [], zodSchema, userLocation, locationGranularity, systemPrompt, timeline, responseFormatName = 'structured_response', customFormat, options = {} }) { // Use the generator version and collect all items const items = []; const generator = this.fetchStructuredDataFromWebStreamGenerator({ model, prompt, recommendedSources, zodSchema, userLocation, locationGranularity, systemPrompt, timeline, responseFormatName, customFormat, options }); for await (const item of generator) { items.push(item); } console.log('[LLMBase] Streaming complete. Items collected:', items.length); const result = { [responseFormatName]: items }; console.log('[LLMBase] Result structure:', Object.keys(result)); const parsed = zodSchema.parse(result); console.log('[LLMBase] Parsed successfully. About to cache...'); // Process and cache results await this.processAndCacheResults(parsed, { prompt, locationGranularity, userLocation, timeline, responseFormatName, model: model || this.defaultModel, options }); return parsed; } /** * Generator version that yields items as they are parsed from the stream */ async *fetchStructuredDataFromWebStreamGenerator({ model, prompt, recommendedSources = [], zodSchema, userLocation, locationGranularity, systemPrompt, timeline, responseFormatName = 'structured_response', customFormat, options = {} }) { if (this.isTestMode()) { return; } const userPrompt = this.buildUserPrompt(prompt, locationGranularity); systemPrompt = systemPrompt?.trim() || 'You are a helpful assistant.'; try { // Build the JSON schema format for response_format const formatSpec = customFormat ? customFormat(zodSchema, responseFormatName) : (0, zod_1.zodTextFormat)(zodSchema, responseFormatName); const stream = await this.openai.responses.create({ model: model || this.defaultModel, stream: true, input: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ], tools: [ { type: 'web_search_preview', user_location: userLocation } ], text: { format: formatSpec } }); // Stream parser for real-time item extraction let accumulatedContent = ''; let isInArray = false; let currentObjectBuffer = ''; let braceDepth = 0; let inString = false; let escapeNext = false; for await (const chunk of stream) { // Handle response.output_text.delta events if (chunk.type === 'response.output_text.delta' && chunk.delta) { console.log(`[LLMBase] Chunk received (${chunk.delta.length} chars): "${chunk.delta.substring(0, 50)}${chunk.delta.length > 50 ? '...' : ''}"`); accumulatedContent += chunk.delta; // Process each character for intelligent parsing for (const char of chunk.delta) { // Handle escape sequences if (escapeNext) { currentObjectBuffer += char; escapeNext = false; continue; } if (char === '\\' && inString) { escapeNext = true; currentObjectBuffer += char; continue; } // Track string boundaries if (char === '"' && !escapeNext) { inString = !inString; } // Only process structural characters when not in a string if (!inString) { // Detect start of data array if (!isInArray && char === '[') { const precedingText = accumulatedContent.slice(Math.max(0, accumulatedContent.length - 20)); console.log(`[LLMBase] Checking for array start. Char: '[', preceding: "${precedingText}"`); if (precedingText.includes(`"${responseFormatName}"`)) { console.log(`[LLMBase] ✅ Found start of "${responseFormatName}" array!`); isInArray = true; currentObjectBuffer = ''; continue; } } // Track object boundaries if (isInArray) { if (char === '{') { if (braceDepth === 0) { currentObjectBuffer = '{'; } else { currentObjectBuffer += char; } braceDepth++; } else if (char === '}') { currentObjectBuffer += char; braceDepth--; if (braceDepth === 0 && currentObjectBuffer.trim().startsWith('{')) { // We have a complete object! console.log(`[LLMBase] Potential complete object found (${currentObjectBuffer.length} chars)`); try { const item = JSON.parse(currentObjectBuffer); // Yield the item immediately for true streaming yield item; console.log(`[LLMBase] ✅ Streaming: Yielded item in real-time:`, JSON.stringify(item).substring(0, 100) + '...'); currentObjectBuffer = ''; } catch (e) { // Failed to parse, might be malformed console.warn('[LLMBase] ❌ Failed to parse potential object:', e.message); console.warn('[LLMBase] Buffer was:', currentObjectBuffer.substring(0, 200) + '...'); currentObjectBuffer = ''; } } } else if (char === ']' && braceDepth === 0) { // End of array isInArray = false; } else if (braceDepth > 0) { currentObjectBuffer += char; } } } else { // Always add characters when in a string if (braceDepth > 0) { currentObjectBuffer += char; } } } } } console.log(`[LLMBase] Stream complete. Total content: ${accumulatedContent.length}`); } catch (error) { console.error('[LLMBase] Error in fetchStructuredDataFromWebStreamGenerator:', error); throw error; } } /** * Non-streaming version using responses.parse API */ /** * Build user prompt with date and location */ buildUserPrompt(prompt, locationGranularity, additionalGuidance) { const today = new Date(); const todayDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; let 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.`; if (additionalGuidance) { userPrompt += ` ${additionalGuidance}`; } return userPrompt; } /** * Check if error is a JSON parsing error */ isJsonParsingError(error) { return error?.message?.includes('Unterminated string in JSON') || error?.message?.includes('Unexpected end of JSON input') || error?.message?.includes('JSON at position'); } /** * Parse response with fallback to salvaging partial JSON */ async parseResponseWithFallback(content, zodSchema, responseFormatName) { try { const jsonResponse = JSON.parse(content); return zodSchema.parse(jsonResponse); } catch (parseError) { console.warn('[LLMBase] Response parse failed, attempting to salvage:', parseError.message); try { const salvaged = salvagePartialJSON(content, responseFormatName); const parsed = zodSchema.parse(salvaged); console.log('[LLMBase] Successfully salvaged response'); return parsed; } catch (salvageError) { console.error('[LLMBase] Failed to salvage response:', salvageError); return zodSchema.parse({ [responseFormatName]: [] }); } } } /** * Process and cache results only if we have actual data */ async processAndCacheResults(parsed, context) { const { prompt, locationGranularity, userLocation, timeline, responseFormatName, model, options } = context; // Check if we have actual results const results = parsed[responseFormatName]; const hasResults = Array.isArray(results) && results.length > 0; // Enhance images if we have results if (hasResults) { await this.enhanceWithImages(results, responseFormatName); // Only cache if we have results const cacheLoc = { area: locationGranularity, region: userLocation?.region, country: userLocation?.country, timeline, buttonClickCount: options.buttonClickCount ? String(options.buttonClickCount) : undefined, lat: options.lat, lng: options.lng, radius: options.radius }; // Only cache if we have meaningful results const hasData = parsed && ((Array.isArray(parsed) && parsed.length > 0) || (typeof parsed === 'object' && parsed !== null && Object.keys(parsed).some(key => Array.isArray(parsed[key]) && parsed[key].length > 0))); if (hasData) { console.log('[LLMBase] Attempting to cache result with data:', { promptLength: prompt.length, location: cacheLoc, dataKeys: Object.keys(parsed), dataLength: Array.isArray(parsed) ? parsed.length : (typeof parsed === 'object' ? Object.keys(parsed).length : 0) }); this.cache.setCachedResult(prompt, cacheLoc, parsed, { model, provider: this.getProviderName(), ...options }).catch(error => { console.error('[LLMBase] Failed to cache result (non-blocking):', error.message); }); } else { console.log('[LLMBase] Skipping cache write - no meaningful data to cache'); } } } /** * Helper method to parse streamed content */ async parseStreamedContent(content, zodSchema, responseFormatName) { try { // First try to parse as JSON const jsonResponse = JSON.parse(content); return zodSchema.parse(jsonResponse); } catch (parseError) { console.warn('[LLMBase] Streaming response parse failed, attempting to salvage:', parseError.message); // Try to salvage partial JSON try { const salvaged = salvagePartialJSON(content, responseFormatName); const parsed = zodSchema.parse(salvaged); console.log('[LLMBase] Successfully salvaged streaming response'); return parsed; } catch (salvageError) { console.error('[LLMBase] Failed to salvage streaming response:', salvageError); return zodSchema.parse({ [responseFormatName]: [] }); } } } /** * Fetches structured data from web search * * @param options.resultLimit - Maximum number of results to request (default: 20) * @param options.useStreaming - Use streaming implementation (default: true for better reliability) * * Note: The responses.parse API doesn't support max_tokens parameter. * Token limits are controlled by the model's context window. * We use result limiting in the prompt to prevent response truncation. */ async fetchStructuredData({ model, prompt, html, zodSchema, responseFormatName = 'structured_response', }) { if (this.isTestMode()) { return { [responseFormatName]: [] }; } let response; let parsed; try { response = await this.openai.responses.parse({ model: model || this.defaultModel, // Note: responses.parse API doesn't support max_tokens parameter 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) }, }); parsed = zodSchema.parse(response.output_parsed); } catch (error) { // Handle JSON parsing errors specifically if (error?.message?.includes('Unterminated string in JSON') || error?.message?.includes('Unexpected end of JSON input') || error?.message?.includes('JSON at position')) { console.error('[LLMBase] JSON truncation error in fetchStructuredData. Attempting to salvage partial data.'); console.error('[LLMBase] Error:', error.message); // Try to salvage partial JSON from the raw output if (response?.output) { console.log('[LLMBase] Raw output length:', response.output?.length || 0); try { // Use our salvage function to extract complete objects const salvaged = salvagePartialJSON(response.output, responseFormatName); // Try to validate the salvaged data with the schema parsed = zodSchema.parse(salvaged); console.log('[LLMBase] Successfully salvaged and validated partial data'); } catch (salvageError) { console.error('[LLMBase] Failed to salvage partial JSON:', salvageError); // If salvaging fails, return empty result with the expected structure parsed = zodSchema.parse({ [responseFormatName]: [] }); console.log('[LLMBase] Returning empty result structure'); } } else { // No output to salvage, return empty structure parsed = zodSchema.parse({ [responseFormatName]: [] }); } } else { // Re-throw non-JSON parsing errors throw error; } } return parsed; } /** * 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; } } /** * Generate embedding for a given text * Used for custom vector operations */ async generateEmbedding(text) { throw new Error('generateEmbedding must be implemented in subclass'); } /** * Execute all strategies in parallel and stream results with deduplication */ async *executeParallelStrategyStream({ prompt, area, region, country, timeline, systemPrompt, options = {}, enableWebSearchLLM = true, lat, lng, radius }) { // Determine which strategies to run const strategies = ['query_cache', 'rag_cache', 'rag_vector', 'rag_hybrid']; // Check if web search should be enabled let webSearchEnabled = enableWebSearchLLM; // Check remote config if available try { // Import firebase config helper const { getRemoteConfigParam } = await Promise.resolve().then(() => __importStar(require('../config/firebase-config'))); // Check if web search is enabled in remote config const remoteWebSearchValue = await getRemoteConfigParam('enable_web_search_llm_for_parallel'); // If remote config explicitly disables it, respect that if (remoteWebSearchValue === 'false' || remoteWebSearchValue === '0') { webSearchEnabled = false; console.log('[ParallelStream] Web search disabled by remote config'); } else if (remoteWebSearchValue === 'true' || remoteWebSearchValue === '1') { webSearchEnabled = true; console.log('[ParallelStream] Web search enabled by remote config'); } // Otherwise, use the parameter value } catch (error) { console.warn('[ParallelStream] Failed to check remote config:', error); // Continue with the parameter value } if (webSearchEnabled) { strategies.push('web_search_llm'); } yield { type: 'parallel_start', strategies, timestamp: Date.now() }; // Try to derive coordinates from area if not provided let derivedLat = lat; let derivedLng = lng; if (area && (derivedLat === undefined || derivedLng === undefined)) { try { const { LocationService } = await Promise.resolve().then(() => __importStar(require('../services/location-service'))); const locationService = new LocationService(); const location = await locationService.getLocationFromArea(area); if (location) { derivedLat = location.lat; derivedLng = location.lng; console.log(`[ParallelStream] Derived coordinates for ${area}: ${derivedLat}, ${derivedLng}`); } } catch (error) { console.warn('[ParallelStream] Failed to derive coordinates from area:', error); } } // Build common parameters const params = { prompt, area, region, country, timeline: timeline || this.inferTimelineFromQuery(prompt), systemPrompt, options, lat: derivedLat, lng: derivedLng, radius }; // Track seen items to prevent duplicates const seenItems = new Map(); // id/title -> source strategy const getItemKey = (item) => { // Use ID if available, otherwise use title return item.id || item.title || item.name || JSON.stringify(item).substring(0, 100); }; // Create async generators for each strategy with error isolation const generators = strategies.map(strategy => ({ strategy, generator: this.createSafeStrategyStream(strategy, params), done: false, errorCount: 0 })); // Process all generators concurrently while (generators.some(g => !g.done)) { // Get next result from each generator with timeout const promises = generators .filter(g => !g.done) .map(async (g) => { try { // Create a timeout promise - doubled to 120 seconds const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error(`Strategy ${g.strategy} timed out after 120 seconds`)), 120000); }); // Race between the actual operation and timeout const result = await Promise.race([ g.generator.next(), timeoutPromise ]); return { strategy: g.strategy, result, generator: g }; } catch (error) { console.error(`[ParallelStream] Error in ${g.strategy}:`, error); g.errorCount++; // Check if it's a timeout error const isTimeout = error instanceof Error && error.message.includes('timed out'); // Mark as done after 3 consecutive errors or immediately on timeout if (g.errorCount >= 3 || isTimeout) { console.warn(`[ParallelStream] ${g.strategy} ${isTimeout ? 'timed out' : 'failed 3 times'}, marking as done`); g.done = true; } // Emit error event but continue processing return { strategy: g.strategy, result: { done: false, value: { type: 'error', error: error instanceof Error ? error.message : String(error), strategy: g.strategy, isTimeout } }, generator: g }; } }); // Wait for all results using allSettled to handle errors gracefully const settled = await Promise.allSettled(promises); const results = settled.map(s => s.status === 'fulfilled' ? s.value : null); if (results) { for (const result of results) { if (!result) continue; const { strategy, result: iterResult, generator } = result; if (iterResult.done) { generator.done = true; yield { type: 'strategy_complete', strategy }; } else if (iterResult.value) { const event = iterResult.value; // Debug log if (event.type === 'data_item') { console.log(`[ParallelStream] Processing data_item from ${strategy}:`, { hasItem: !!event.item, itemKeys: event.item ? Object.keys(event.item).slice(0, 5) : [] }); } // Handle data items with deduplication if (event.type === 'data_item' && event.item) { const itemKey = getItemKey(event.item); const existingSource = seenItems.get(itemKey); if (!existingSource) { // New item, add it seenItems.set(itemKey, strategy); yield { ...event, source: strategy, strategy: strategy }; } else { // Duplicate item, skip it yield { type: 'duplicate_skipped', itemId: itemKey.substring(0, 50), strategy }; } } else { // Pass through other event types yield event; } } } } } } /** * Get appropriate stream for each strategy */ async *getStrategyStream(strategy, params) { switch (strategy) { case 'query_cache': yield* this.streamQueryCache(params); break; case 'rag_cache': yield* this.streamRAGCache(params); break; case 'rag_vector': yield* this.streamVectorSearch(params); break; case 'rag_hybrid': yield* this.streamHybridSearch(params); break; case 'web_search_llm': yield* this.streamWebSearch(params); break; } } /** * Create a safe wrapper around strategy streams that catches all errors */ async *createSafeStrategyStream(strategy, params) { let hasStarted = false; const startTime = Date.now(); const STRATEGY_TIMEOUT = 120000; // 120 seconds timeout for individual strategies try { hasStarted = true; yield { type: 'strategy_start', strategy, timestamp: Date.now() }; // Wrap the inner generator to catch any async errors const innerGenerator = this.getStrategyStream(strategy, params); try { for await (const event of innerGenerator) { // Check if we've exceeded the timeout if (Date.now() - startTime > STRATEGY_TIMEOUT) { console.error(`[SafeStrategyStream] Strategy ${strategy} exceeded timeout of ${STRATEGY_TIMEOUT}ms`); yield { type: 'error', error: `Strategy timed out after ${STRATEGY_TIMEOUT / 1000} seconds`, strategy, isTimeout: true }; break; } yield event; } } catch (innerError) { console.error(`[SafeStrategyStream] Inner generator error in ${strategy}:`, innerError); yield { type: 'error', error: innerError instanceof Error ? innerError.message : String(innerError), strategy }; } } catch (error) { console.error(`[SafeStrategyStream] Fatal error in ${strategy}:`, error); if (hasStarted) { yield { type: 'error', error: error instanceof Error ? error.message : String(error), strategy }; } } finally { if (hasStarted) { yield { type: 'strategy_complete', strategy }; } } } /** * Stream results from Firestore query cache */ async *streamQueryCache(params) { const queryCache = new query_cache_1.default(); const cacheLoc = { area: params.area, region: params.region, country: params.country, timeline: params.timeline, buttonClickCount: params.options?.buttonClickCount, lat: params.lat, lng: params.lng, radius: params.radius }; try { // Get cached results with pagination const batchSize = 5; let lastScore = 1.0; let hasMore = true; while (hasMore) { const results = await queryCache.getCachedResultsBatch(params.prompt, cacheLoc, batchSize, lastScore); if (!results || results.length === 0) { hasMore = false; break; } for (const result of results) { if (result.data && Array.isArray(result.data)) { for (const item of result.data) { yield { type: 'data_item', item: { ...item, _source: 'query_cache' }, strategy: 'query_cache', source: 'query_cache' }; // Tiny delay for progressive streaming await new Promise(resolve => setTimeout(resolve, 5)); } } } // Update cursor for next batch if (results.length < batchSize) { hasMore = false; } else { lastScore = results[results.length - 1].score || 0; } } } catch (error) { console.error('[StreamQueryCache] Error:', error); // Emit error event but don't throw - let other strategies continue yield { type: 'error', error: error.message || 'Query cache search failed', strategy: 'query_cache' }; } } /** * Stream results from Supabase RAG cache */ async *streamRAGCache(params) { try { const { searchQueryCache } = await Promise.resolve().then(() => __importStar(require('../supabase/query-cache-supabase'))); // Using geographic search instead of area hierarchy // Add timeout to the search operation - doubled to 60 seconds const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('RAG cache search timed out after 60 seconds')), 60000); }); const results = await Promise.race([ searchQueryCache({ query: params.prompt, area: params.area, region: params.region, country: params.country, timeline: params.timeline, lat: params.lat, lng: params.lng, radius: params.radius, limit: 20 }), timeoutPromise ]); if (results && results.length > 0) { for (const result of results) { // Check both 'result' and 'response' fields for backward compatibility const data = result.result || result.response; if (data) { // Handle both array and object with 'data' property const items = Array.isArray(data) ? data : (data.data || []); if (Array.isArray(items)) { for (const item of items) { yield { type: 'data_item', item: { ...item, _source: 'rag_cache' }, strategy: 'rag_cache', source: 'rag_cache' }; // Tiny delay to allow streaming to feel progressive await new Promise(resolve => setTimeout(resolve, 5)); } } } } } } catch (error) { console.error('[StreamRAGCache] Error:', error); // Emit error event but don't throw - let other strategies continue yield { type: 'error', error: error.message || 'RAG cache search failed', strategy: 'rag_cache' }; } } /** * Stream results from Supabase vector search */ async *streamVectorSearch(params) { try { // Add timeout to the search operation - doubled to 60 seconds const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Vector search timed out after 60 seconds')), 60000); }); // Pass location filters and geographic parameters directly to searchContent const searchOptions = { query: params.prompt, limit: 20, filters: { area: params.area, region: params.region, // Note: country field might need to be added to the content table's data JSONB // For now, we'll filter by area and region which are already indexed } }; // Add geographic parameters if provided if (params.lat !== undefined && params.lng !== undefined) { console.log(`[StreamVectorSearch] Using vector search with geographic parameters at ${params.lat}, ${params.lng}`); searchOptions.lat = params.lat; searchOptions.lng = params.lng; searchOptions.radius = params.radius || 10; } const results = await Promise.race([ (0, vector_search_1.searchContent)(searchOptions), timeoutPromise ]); if (results && results.length > 0) { for (const result of results) { yield { type: 'data_item', item: { ...result, _source: 'rag_vector' }, strategy: 'rag_vector', source: 'rag_vector' }; // Tiny delay for progressive streaming await new Promise(resolve => setTimeout(resolve, 5)); } } } catch (error) { console.error('[StreamVectorSearch] Error:', error); // Emit error event but don't throw - let other strategies continue yield { type: 'error', error: error.message || 'Vector search failed', strategy: 'rag_vector' }; } } /** * Stream results from Supabase hybrid search */ async *streamHybridSearch(params) { try { // Add timeout to the search operation - doubled to 60 seconds const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Hybrid search timed out after 60 seconds')), 60000); }); // Build search options with filters and geographic parameters const searchOptions = { query: params.prompt, limit: 20, vectorWeight: 0.7, textWeight: 0.3, filters: { area: params.area, region: params.region, } }; // Add geographic parameters if provided if (params.lat !== undefined && params.lng !== undefined) { console.log(`[StreamHybridSearch] Using hybrid search with geographic parameters at ${params.lat}, ${params.lng}`); searchOptions.lat = params.lat; searchOptions.lng = params.lng; searchOptions.radius = params.radius || 10; } const results = await Promise.race([ (0, vector_search_1.hybridSearch)(params.prompt, searchOptions), timeoutPromise ]); if (results && results.length > 0) { for (const result of results) { yield { type: 'data_item', item: { ...result, _source: 'rag_hybrid' }, strategy: 'rag_hybrid', source: 'rag_hybrid' }; // Tiny delay for progressive streaming await new Promise(resolve => setTimeout(resolve, 5)); } } } catch (error) { console.error('[StreamHybridSearch] Error:', error); // Emit error event but don't throw - let other strategies continue yield { type: 'error', error: error.message || 'Hybrid search failed', strategy: 'rag_hybrid' }; } } /** * Stream results from web search */ async *streamWebSearch(params) { try { // Get categories from params or use defaults const categories = params.options?.categories || (params.options?.category ? [params.options.category] : (0, content_types_config_1.getEnabledContentTypes)()); // Use utility function to build schemas const ResponseSchema = (0, buildUnionSchema_1.buildResponseSchema)(categories); // Use the new generator-based streaming method const generator = this.fetchStructuredDataFromWebStreamGenerator({ prompt: params.prompt, zodSchema: ResponseSchema, userLocation: { type: 'approximate', country: params.country || 'US', region: params.region, city: params.area }, locationGranularity: params.area, systemPrompt: params.systemPrompt, timeline: params.timeline, responseFormatName: 'data', options: { useStreaming: true, resultLimit: 20 } }); // Create timeout flag let timedOut = false; const timeout = setTimeout(() => { timedOut = true; }, 120000); // 120 seconds // Collect items for caching const collectedItems = []; try { let itemCount = 0; for await (const item of generator) { if (timedOut) { throw new Error('Web search timed out after 120 seconds'); } itemCount++; // Enhance individual item with image await this.enhanceWithImages([item], 'data'); // Collect item for caching collectedItems.push(item); // Yield each item as it comes from the generator yield { type: 'data_item', item: { ...item, _source: 'web_search_llm' }, strategy: 'web_search_llm', source: 'web_search_llm' }; // Optional: Add tiny delay (10ms) to prevent overwhelming the client await new Promise(resolve => setTimeout(resolve, 10)); } console.log(`[StreamWebSearch] Successfully streamed ${itemCount} items`); // Cache the results after successful streaming if (collectedItems.length > 0) { const cacheData = { data: collectedItems }; await this.processAndCacheResults(cacheData, { prompt: params.prompt, locationGranularity: params.area, userLocation: { type: 'approximate', country: params.country || 'US', region: params.region, city: params.area }, timeline: params.timeline, responseFormatName: 'data', model: this.defaultModel, options: { ...(params.options || {}), lat: params.lat, lng: params.lng, radius: params.