adcortex-js
Version:
SDK to integrate adcortex into your website
503 lines (471 loc) • 18.4 kB
JavaScript
/**
* Chat Client for ADCortex API with sequential message processing.
*
* This client provides a synchronous interface for integrating contextual
* advertisements from ADCortex into chat applications. It features message queue
* management, circuit breaker pattern for error handling, and automatic retries.
*/
import { v4 as uuidv4 } from 'uuid';
import axios from 'axios';
import axiosRetry, { isNetworkOrIdempotentRequestError } from 'axios-retry';
import { AdResponseSchema, MessageSchema, Role } from './types.js';
import { ClientState, CircuitBreaker } from './state.js';
const DEFAULT_CONTEXT_TEMPLATE = "Here is a product the user might like: {ad_title} - {ad_description}: here is a sample way to present it: {placement_template}";
const AD_FETCH_URL = "https://adcortex.3102labs.com/ads/matchv2";
axiosRetry(axios, {
retries: 3, // number of retries
retryDelay: axiosRetry.exponentialDelay, // exponential backoff
retryCondition: (error) => {
// Retry if it's a network error, idempotent request error, or connection aborted
return (isNetworkOrIdempotentRequestError(error) || error.code === "ECONNABORTED");
},
});
/**
* Synchronous chat client for ADCortex API with message queue and circuit breaker support.
*
* This client provides features for integrating contextual advertisements into chat applications:
* - Message queue management with FIFO behavior
* - Circuit breaker pattern for handling API errors
* - Batch processing of messages
* - Automatic retries with exponential backoff
*
* @example
* ```javascript
* // Create session info
* const sessionInfo = SessionInfoSchema.parse({
* session_id: "session123",
* character_name: "Assistant",
* character_metadata: "Friendly AI assistant",
* user_info: {
* user_id: "user456",
* age: 25,
* gender: "male",
* location: "US",
* language: "en",
* interests: ["technology", "gaming"]
* },
* platform: {
* name: "MyApp",
* varient: "1.0.0"
* }
* });
*
* // Create chat client
* const chatClient = new AdcortexChatClient(sessionInfo);
*
* // Process a message
* await chatClient.__call__(Role.user, "I need a new gaming laptop");
*
* // Check for ads
* const latestAd = chatClient.get_latest_ad();
* if (latestAd) {
* const context = chatClient.create_context(latestAd);
* console.log("Ad Context:", context);
* }
* ```
*/
class AdcortexChatClient {
/**
* Creates a new instance of the AdcortexChatClient.
*
* @param {Object} session_info - Session and user information for ad targeting
* @param {string} [context_template=DEFAULT_CONTEXT_TEMPLATE] - Template string for formatting ad context
* @param {string|null} [api_key=null] - API key for ADCortex API, reads from environment if not provided
* @param {number} [timeout=5] - Request timeout in seconds
* @param {boolean} [disable_logging=false] - Whether to disable logging
* @param {number} [max_queue_size=100] - Maximum number of messages in the queue
* @param {number} [circuit_breaker_threshold=5] - Number of consecutive errors before circuit opens
* @param {number} [circuit_breaker_timeout=120] - Time in seconds before circuit resets (2 minutes)
*
* @throws {Error} if ADCORTEX_API_KEY is not provided and not found in environment
*/
constructor(session_info, context_template = DEFAULT_CONTEXT_TEMPLATE, api_key = null, timeout = 5,
// log_level: number|null = 40,
disable_logging = false, max_queue_size = 100, circuit_breaker_threshold = 5, circuit_breaker_timeout = 120 // 2 minutes
) {
this._session_info = session_info;
this._context_template = context_template;
this._api_key = api_key || process.env.ADCORTEX_API_KEY || "";
this._headers = {
"Content-Type": "application/json",
"X-API-KEY": this._api_key,
};
this._timeout = timeout;
this.latest_ad = null;
this._disable_logging = disable_logging;
// Queue management
this._message_queue = [];
this._max_queue_size = max_queue_size;
// State management
this._state = ClientState.IDLE;
// Circuit breaker
this._circuit_breaker = new CircuitBreaker(circuit_breaker_threshold, circuit_breaker_timeout, disable_logging);
// Log level configuration is managed by the logging system in JS
// We're using console.log/error in this implementation
// # Configure logging
// if not disable_logging:
// logger.setLevel(log_level)
// if not logger.handlers:
// handler = logging.StreamHandler()
// formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
// handler.setFormatter(formatter)
// logger.addHandler(handler)
if (!this._api_key) {
throw new Error("ADCORTEX_API_KEY is not set and not provided");
}
}
/**
* Formats a date object into a string with format YYYY-MM-DD HH:MM:SS.
* @private
*
* @param {Date} date - The date to format
* @returns {string} Formatted date string
*/
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // months are 0-indexed
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Logs an info message if logging is enabled.
* @private
*
* @param {string} message - The message to log
*/
_log_info(message) {
/**
* Log info message if logging is enabled.
*/
if (!this._disable_logging) {
console.log(`${this.formatDate(new Date())} - chat_client - INFO - ${message}`);
// console.info(message);
}
}
/**
* Logs an error message if logging is enabled.
* @private
*
* @param {string} message - The error message to log
*/
_log_error(message) {
/**
* Log error message if logging is enabled.
*/
if (!this._disable_logging) {
console.error(`${this.formatDate(new Date())} - chat_client - ERROR - ${message}`);
// console.error(message);
}
}
/**
* Processes a chat message and potentially fetches relevant ads.
*
* This method adds the message to the processing queue. If conditions are met
* (client is idle, message is from user, circuit breaker is closed), it will
* process the queue to fetch relevant advertisements.
*
* @param {string} role - The role of the message sender (user or AI)
* @param {string} content - The content of the message
* @returns {Promise<void>} A Promise that resolves when the message has been processed
*
* @example
* ```javascript
* // Process a user message
* await chatClient.__call__(Role.user, "I need a gaming laptop under $1500");
*
* // Process an AI response (typically won't trigger ad fetch)
* await chatClient.__call__(Role.ai, "I can help you find gaming laptops in that price range!");
* ```
*/
async __call__(role, content) {
/**
* Add a message to the queue and process it.
*/
const current_message = MessageSchema.parse({
role: role,
content: content,
timestamp: new Date().getTime() / 1000 // Convert to seconds for consistency with Python
});
// Always add message to queue, remove oldest if full
if (this._message_queue.length >= this._max_queue_size) {
this._message_queue.shift(); // Remove oldest message
this._log_info("Queue full, removed oldest message");
}
this._message_queue.push(current_message);
this._log_info(`Message queued: ${role} - ${content}`);
// Process queue if not already processing, role is user, and circuit breaker is closed
if (this._state === ClientState.IDLE && role === Role.user && !this._circuit_breaker.is_open()) {
this._state = ClientState.PROCESSING;
try {
await this._process_queue();
}
catch (e) {
// this._log_error(`Processing failed: ${e instanceof Error ? e.message : String(e)}`);
this._log_error(`Processing failed: ${e instanceof Error ? e.message : String(e)}`);
this._circuit_breaker.record_error();
}
finally {
this._state = ClientState.IDLE;
}
}
}
/**
* Processes all messages in the queue as a single batch.
* @private
*
* @returns {Promise<void>} A Promise that resolves when queue processing is complete
*/
async _process_queue() {
/**
* Process all messages in the queue in a single batch.
*/
if (!this._message_queue.length) {
return;
}
// Take a snapshot of current messages
const messages_to_process = [...this._message_queue];
this._log_info(`Processing ${messages_to_process.length} messages in batch`);
try {
await this._fetch_ad_batch(messages_to_process);
// Only remove messages that were successfully processed
this._message_queue = this._message_queue.slice(messages_to_process.length);
}
catch (e) {
if (axios.isAxiosError(e)) {
if (e.code === 'ECONNABORTED') {
this._log_error(`Batch request timed out: ${e.message}`);
}
else {
this._log_error(`Batch request failed: ${e.message}`);
}
}
else if (e instanceof Error) {
if (e.name === 'ValidationError') {
this._log_error(`Invalid response format: ${e.message}`);
}
else {
this._log_error(`Unexpected error processing batch: ${e.message}`);
}
}
else {
this._log_error(`Unknown error: ${String(e)}`);
}
this._circuit_breaker.record_error();
throw e;
}
}
/**
* Fetches an ad based on a batch of messages.
* @private
*
* @param {Array} messages - Array of messages to analyze for ad matching
* @returns {Promise<void>} A Promise that resolves when the fetch is complete
*/
async _fetch_ad_batch(messages) {
/**
* Fetch an ad based on all messages in a batch.
*/
const payload = this._prepare_batch_payload(messages);
console.log(payload);
this._send_request(payload);
// Using ts-retry-promise for retry functionality
// await retry(
// async () => this._send_request(payload),
// {
// retries: 3,
// backoff: 'exponential',
// delay: 1000,
// maxDelay: 10000,
// factor: 2,
// timeout: this._timeout * 1000,
// retryIf: (error) => {
// return axios.isAxiosError(error) &&
// (error.code === 'ECONNABORTED' || error.code === 'ERR_NETWORK');
// }
// }
// );
}
/**
* Prepares the payload for the batch ad request.
* @private
*
* @param {Array} messages - Array of messages to include in the payload
* @returns {Object} Formatted payload object ready to send to the API
*/
_prepare_batch_payload(messages) {
/**
* Prepare the payload for the batch ad request.
*/
// Convert session info to object and handle enum values
const session_info_dict = { ...this._session_info };
const user_info_dict = { ...session_info_dict.user_info };
user_info_dict.interests = user_info_dict.interests;
// Convert messages to dict and handle enum values
const messages_dict = messages.map(msg => ({
...msg,
role: msg.role.toString()
}));
return {
"RGUID": uuidv4(),
"session_info": {
"session_id": session_info_dict.session_id,
"character_name": session_info_dict.character_name,
"character_metadata": session_info_dict.character_metadata,
},
"user_data": user_info_dict,
"messages": messages_dict,
"platform": session_info_dict.platform
};
}
/**
* Sends the request to the ADCortex API.
* @private
*
* @param {Object} payload - The payload to send to the API
* @returns {Promise<void>} A Promise that resolves when the request is complete
*/
async _send_request(payload) {
/**
* Send the request to the ADCortex API.
*/
try {
const response = await axios.post(AD_FETCH_URL, payload, {
headers: this._headers,
timeout: this._timeout * 1000
});
this._handle_response(response.data);
}
catch (e) {
if (axios.isAxiosError(e)) {
if (e.code === 'ECONNABORTED') {
this._log_error("Request timed out");
}
else {
this._log_error(`Error fetching ad: ${e.message}`);
}
}
else {
this._log_error(`Unknown error: ${String(e)}`);
}
throw e;
}
}
/**
* Handles the response from the ad request.
* @private
*
* @param {Object} response_data - The response data from the API
*/
_handle_response(response_data) {
/**
* Handle the response from the ad request.
*/
try {
const parsed_response = AdResponseSchema.parse(response_data);
if (parsed_response.ads && parsed_response.ads.length > 0) {
this.latest_ad = parsed_response.ads[0];
this._log_info(`Ad fetched: ${this.latest_ad.ad_title}`);
}
else {
this._log_info("No ads returned");
}
}
catch (e) {
this._log_error(`Invalid ad response format: ${e instanceof Error ? e.message : String(e)}`);
}
}
/**
* Creates a context string for the provided ad.
*
* This method uses the context template to create a string that can be
* integrated into chat responses to present the ad in a natural way.
*
* @param {Object} latest_ad - The ad to create context for
* @returns {string} Formatted context string with ad information
*
* @example
* ```javascript
* const ad = chatClient.get_latest_ad();
* if (ad) {
* const context = chatClient.create_context(ad);
* console.log(context);
* // Output: "Here is a product the user might like: Gaming Laptop - High performance gaming: Check it out!"
* }
* ```
*/
create_context(latest_ad) {
/**
* Create a context string for the last seen ad.
*/
return this._context_template
.replace('{ad_title}', latest_ad.ad_title)
.replace('{ad_description}', latest_ad.ad_description)
.replace('{placement_template}', latest_ad.placement_template);
}
/**
* Gets the latest ad and clears it from memory.
*
* This method is designed to be called after processing messages to retrieve
* any matched advertisements. Once retrieved, the ad is cleared from memory.
*
* @returns {Object|null} The latest ad, or null if no ad was found
*
* @example
* ```javascript
* await chatClient.__call__(Role.user, "I need a gaming laptop");
* const ad = chatClient.get_latest_ad();
* if (ad) {
* console.log(`Found ad: ${ad.ad_title}`);
* } else {
* console.log("No relevant ads found");
* }
* ```
*/
get_latest_ad() {
/**
* Get the latest ad and clear it from memory.
*/
const latest = this.latest_ad;
this.latest_ad = null;
return latest;
}
/**
* Gets the current client state.
*
* @returns {ClientState} Current state of the client (IDLE or PROCESSING)
*/
get_state() {
/**
* Get current client state.
*/
return this._state;
}
/**
* Checks if the client is in a healthy state.
*
* A client is considered healthy when:
* - The circuit breaker is closed (not in error state)
* - The message queue is not full
*
* @returns {boolean} Boolean indicating if the client is healthy
*
* @example
* ```javascript
* if (!chatClient.is_healthy()) {
* console.log("Client is in an unhealthy state, waiting before sending more messages...");
* // Implement backoff strategy
* }
* ```
*/
is_healthy() {
/**
* Check if the client is in a healthy state.
*/
return (!this._circuit_breaker.is_open()
&& this._message_queue.length < this._max_queue_size);
}
}
export { AdcortexChatClient };