pulse-ai-utils
Version:
Utility functions and helpers for AI-powered applications
1,119 lines • 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 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.