Skip to content
Merged
172 changes: 172 additions & 0 deletions spec/src/modules/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,141 @@ describe(`ConstructorIO - Agent${bundledDescriptionSuffix}`, () => {
expect(url).not.contain(' ');
expect(url).contain(encodeURIComponentRFC3986(intentWithSpaces));
});

it('should include threadId parameter when provided', () => {
const url = createAgentUrl(
'running shoes',
{ ...defaultParameters, threadId: 'test-thread-id' },
defaultOptions,
);
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('thread_id').to.equal('test-thread-id');
});

it('should include guard parameter when provided as true', () => {
const url = createAgentUrl(
'running shoes',
{ ...defaultParameters, guard: true },
defaultOptions,
);
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('guard').to.equal('true');
});

it('should include guard parameter when provided as false', () => {
const url = createAgentUrl(
'running shoes',
{ ...defaultParameters, guard: false },
defaultOptions,
);
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('guard').to.equal('false');
});

it('should include numResultsPerEvent parameter when provided', () => {
const url = createAgentUrl(
'running shoes',
{ ...defaultParameters, numResultsPerEvent: 5 },
defaultOptions,
);
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('num_results_per_event').to.equal('5');
});

it('should include numResultEvents parameter when provided', () => {
const url = createAgentUrl(
'running shoes',
{ ...defaultParameters, numResultEvents: 3 },
defaultOptions,
);
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('num_result_events').to.equal('3');
});

it('should include numResultsPerPage parameter when provided', () => {
const url = createAgentUrl(
'running shoes',
{ ...defaultParameters, numResultsPerPage: 10 },
defaultOptions,
);
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('num_results_per_page').to.equal('10');
});

it('should include preFilterExpression as JSON string when provided as object', () => {
const preFilterExpression = { and: [{ name: 'brand', value: 'Nike' }] };
const url = createAgentUrl(
'running shoes',
{ ...defaultParameters, preFilterExpression },
defaultOptions,
);
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('pre_filter_expression').to.equal(JSON.stringify(preFilterExpression));
});

it('should include qsParam as JSON string when provided as object', () => {
const qsParam = { section: 'Products' };
const url = createAgentUrl(
'running shoes',
{ ...defaultParameters, qsParam },
defaultOptions,
);
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('qs').to.equal(JSON.stringify(qsParam));
});

it('should include fmtOptions when provided', () => {
const fmtOptions = { fields: ['title', 'image_url'], hidden_fields: ['price'] };
const url = createAgentUrl(
'running shoes',
{ ...defaultParameters, fmtOptions },
defaultOptions,
);
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('fmt_options');
expect(requestedUrlParams.fmt_options).to.have.property('fields');
expect(requestedUrlParams.fmt_options).to.have.property('hidden_fields');
});

it('should include user segments when provided in options', () => {
const url = createAgentUrl('running shoes', defaultParameters, {
...defaultOptions,
segments: ['segment1', 'segment2'],
});
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('us');
});

it('should include userId when provided in options', () => {
const url = createAgentUrl('running shoes', defaultParameters, {
...defaultOptions,
userId: 'user-123',
});
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('ui').to.equal('user-123');
});

it('should include testCells when provided in options', () => {
const url = createAgentUrl('running shoes', defaultParameters, {
...defaultOptions,
testCells: { cellA: 'variant1', cellB: 'variant2' },
});
const requestedUrlParams = qs.parse(url.split('?')?.[1]);

expect(requestedUrlParams).to.have.property('ef-cellA').to.equal('variant1');
expect(requestedUrlParams).to.have.property('ef-cellB').to.equal('variant2');
});
});

// setupEventListeners util Tests
Expand Down Expand Up @@ -196,6 +331,42 @@ describe(`ConstructorIO - Agent${bundledDescriptionSuffix}`, () => {
done();
});
});

it('should enqueue MESSAGE event data into the stream', (done) => {
const eventType = Agent.EventTypes.MESSAGE;
const eventData = { text: 'Here are some recommendations' };

setupEventListeners(mockEventSource, mockStreamController, Agent.EventTypes);

const messageCallback = mockEventSource.addEventListener
.getCalls()
.find((call) => call.args[0] === eventType).args[1];

messageCallback({ data: JSON.stringify(eventData) });

setImmediate(() => {
expect(mockStreamController.enqueue.calledWith({ type: eventType, data: eventData })).to.be.true;
done();
});
});

it('should enqueue FOLLOW_UP_QUESTIONS event data into the stream', (done) => {
const eventType = Agent.EventTypes.FOLLOW_UP_QUESTIONS;
const eventData = { questions: ['What size?', 'What color?'] };

setupEventListeners(mockEventSource, mockStreamController, Agent.EventTypes);

const followUpCallback = mockEventSource.addEventListener
.getCalls()
.find((call) => call.args[0] === eventType).args[1];

followUpCallback({ data: JSON.stringify(eventData) });

setImmediate(() => {
expect(mockStreamController.enqueue.calledWith({ type: eventType, data: eventData })).to.be.true;
done();
});
});
});

describe('getAgentResultsStream', () => {
Expand Down Expand Up @@ -265,4 +436,5 @@ describe(`ConstructorIO - Agent${bundledDescriptionSuffix}`, () => {
reader.cancel();
});
});

});
69 changes: 63 additions & 6 deletions src/modules/agent.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { cleanParams, trimNonBreakingSpaces, encodeURIComponentRFC3986, stringify } = require('../utils/helpers');
const { cleanParams, trimNonBreakingSpaces, encodeURIComponentRFC3986, stringify, isNil } = require('../utils/helpers');

// Create URL from supplied intent (term) and parameters
// eslint-disable-next-line complexity
function createAgentUrl(intent, parameters, options) {
const {
apiKey,
Expand All @@ -26,7 +27,7 @@ function createAgentUrl(intent, parameters, options) {
}

// Validate domain is provided
if (!parameters.domain || typeof parameters.domain !== 'string') {
if (!parameters || !parameters.domain || typeof parameters.domain !== 'string') {
throw new Error('parameters.domain is a required parameter of type string');
}

Expand All @@ -48,7 +49,17 @@ function createAgentUrl(intent, parameters, options) {
}

if (parameters) {
const { domain, numResultsPerPage } = parameters;
const {
domain,
numResultsPerPage,
threadId,
guard,
numResultsPerEvent,
numResultEvents,
qsParam,
preFilterExpression,
fmtOptions,
} = parameters;

// Pull domain from parameters
if (domain) {
Expand All @@ -59,6 +70,41 @@ function createAgentUrl(intent, parameters, options) {
if (numResultsPerPage) {
queryParams.num_results_per_page = numResultsPerPage;
}

// Pull thread_id from parameters
if (threadId) {
queryParams.thread_id = threadId;
}

// Pull guard from parameters
if (!isNil(guard)) {
queryParams.guard = guard;
}

// Pull num_results_per_event from parameters
if (numResultsPerEvent) {
queryParams.num_results_per_event = numResultsPerEvent;
}

// Pull num_result_events from parameters
if (numResultEvents) {
queryParams.num_result_events = numResultEvents;
}

// Pull qsParam from parameters
if (qsParam) {
queryParams.qs = JSON.stringify(qsParam);
}

// Pull pre_filter_expression from parameters
if (preFilterExpression) {
queryParams.pre_filter_expression = JSON.stringify(preFilterExpression);
}

// Pull fmt_options from parameters
if (fmtOptions) {
queryParams.fmt_options = fmtOptions;
}
}

// eslint-disable-next-line no-underscore-dangle
Expand Down Expand Up @@ -124,6 +170,8 @@ class Agent {
RECIPE_INSTRUCTIONS: 'recipe_instructions', // Represents recipe instructions
SERVER_ERROR: 'server_error', // Server Error event
IMAGE_META: 'image_meta', // This event type is used for enhancing recommendations with media content such as images
MESSAGE: 'message', // Represents a textual message from the agent
FOLLOW_UP_QUESTIONS: 'follow_up_questions', // Represents follow-up question suggestions
END: 'end', // Represents the end of data stream
};

Expand All @@ -133,9 +181,18 @@ class Agent {
* @function getAgentResultsStream
* @description Retrieve a stream of agent results from Constructor.io API
* @param {string} intent - Intent to use to perform an intent based recommendations
* @param {object} [parameters] - Additional parameters to refine result set
* @param {string} [parameters.domain] - domain name e.g. swimming sports gear, groceries
* @param {number} [parameters.numResultsPerPage] - The total number of results to return
* @param {object} parameters - Additional parameters to refine result set
* @param {string} parameters.domain - Domain name (e.g. "groceries", "recipes")
* @param {string} [parameters.threadId] - Conversation thread ID for multi-turn dialogue
* @param {boolean} [parameters.guard] - Enable content moderation
* @param {number} [parameters.numResultsPerEvent] - Max products per search_result event
* @param {number} [parameters.numResultEvents] - Max number of search_result events
* @param {number} [parameters.numResultsPerPage] - Deprecated: use numResultsPerEvent instead
* @param {object} [parameters.preFilterExpression] - Faceting expression to scope search results. Please refer to https://docs.constructor.com/reference/configuration-collections
* @param {object} [parameters.fmtOptions] - The format options used to refine result groups. Please refer to https://docs.constructor.com/reference/v1-asa-retrieve-intent#query-params for details
* @param {string[]} [parameters.fmtOptions.fields] - Product fields to return
* @param {string[]} [parameters.fmtOptions.hidden_fields] - Hidden fields to return
* @param {object} [parameters.qsParam] - Parameters listed above can be serialized into a JSON object and parsed through this parameter. Please refer to https://docs.constructor.com/reference/v1-asa-retrieve-intent#query-params
* @returns {ReadableStream} Returns a ReadableStream.
* @example
* const readableStream = constructorio.agent.getAgentResultsStream('I want to get shoes', {
Expand Down
10 changes: 10 additions & 0 deletions src/types/agent.d.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import {
ConstructorClientOptions,
FmtOptions,
FilterExpression,
} from '.';

export default Agent;

export interface IAgentParameters {
domain: string;
/** @deprecated Use numResultsPerEvent instead */
numResultsPerPage?: number;
filters?: Record<string, any>;
threadId?: string;
guard?: boolean;
numResultsPerEvent?: number;
numResultEvents?: number;
qsParam?: Record<string, any>;
preFilterExpression?: FilterExpression;
fmtOptions?: Pick<FmtOptions, 'fields' | 'hidden_fields'>;
}

declare class Agent {
Expand Down
1 change: 1 addition & 0 deletions src/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default ConstructorIO;
export * from './search';
export * from './autocomplete';
export * from './quizzes';
export * from './agent';
export * from './recommendations';
export * from './browse';
export * from './tracker';
Expand Down
Loading