Skip to content

Commit

Permalink
feat(headless): creation of the new headless insight user actions con…
Browse files Browse the repository at this point in the history
…troller (#4192)

# [SFINT-5613](https://coveord.atlassian.net/browse/SFINT-5613)

## Overview about the user actions feature:

The user actions feature allows the agent using the Insight Panel to
check all the actions that have beed made by a user who created a case,
these knowledge about the actions performed by the user should help the
agent to understand the context of the case and issue the user is facing
and help him solve the case more quickly.

From a UI perspective, that's what this feature look like:
<img width="250" alt="Screenshot 2024-07-16 at 9 21 10 AM"
src="https://github.com/user-attachments/assets/5f326d08-9750-4f27-99a3-331193ade5a0">

## From where we get the user actions data:

We get the user action data from the the following machine learning API:
[/rest/organizations/{organizationId}/machinelearning/user/actions](
https://platformdev.cloud.coveo.com/docs?urls.primaryName=Machine%20Learning#/User%20Profiles/rest_organizations_paramId_machinelearning_user_actions_post)

## Example request:
```
curl -X 'POST' \
  'https://platform.cloud.coveo.com/rest/organizations/MyOrgId/machinelearning/user/actions' \
  -H 'accept: */*' \
  -H 'Authorization: Bearer XXXXXXX' \
  -H 'Content-Type: application/json' \
  -d '{
  "objectId": "MyUserId"
}'
```

## Example response:
```
{
   "executionTime":0.011054937,
   "modelSubType":"userActionHistory",
   "modelType":"Profile",
   "responseTime":1721136841424,
   "value":[
      {
         "name":"SEARCH",
         "value":"{\"cause\":\"userActionLoad\",\"origin_level_1\":\"coveoLightningInsightPanel\",\"origin_level_2\":\"default\"}",
         "time":"1721012966908"
      },
      {
         "name":"CUSTOM",
         "value":"{\"event_type\":\"User Actions\",\"event_value\":\"openUserActions\",\"origin_level_1\":\"coveoLightningInsightPanel\",\"origin_level_2\":\"default\"}",
         "time":"1721012966186"
      },
     ....
   ]
```

## What is being done in the Headless library:

### In this first iteration(this PR):

- Created the Headless user actions controller.
- Created the Headless user actions dispatchable actions.
- Created the Headless user actions slice.
- Created the Headless user actions state.
- Updated the the insight API client to target the correct user actions
endpoint.
- Unit tests created.

### In a future iteration(a following PR):

- Will add the logic that preprocess the data returned by the API, this
logic will organize and filter the data to make it ready to display by
the future UI components:
https://coveord.atlassian.net/browse/SFINT-5639
-  Expose the Headless user actions controller.
- Add the and expose the headless user action actions loader.
- Add more unit tests.



[SFINT-5613]:
https://coveord.atlassian.net/browse/SFINT-5613?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
mmitiche authored Jul 22, 2024
1 parent 2989ff7 commit b52953e
Show file tree
Hide file tree
Showing 17 changed files with 631 additions and 97 deletions.
14 changes: 14 additions & 0 deletions packages/bueno/src/values/string-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,19 @@ describe('string value', () => {
value = new StringValue({regex: /ab/});
expect(value.validate('cd')).toContain('ab');
});

it(`when ISODate is true
when passing an invalid date string
it returns an error description`, () => {
value = new StringValue({ISODate: true});
expect(value.validate('hello')).not.toBeNull();
});

it(`when ISODate is true
when passing a valid URL value
it returns null`, () => {
value = new StringValue({ISODate: true});
expect(value.validate('2024-07-16T22:02:06.553Z')).toBeNull();
});
});
});
15 changes: 14 additions & 1 deletion packages/bueno/src/values/string-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ interface StringValueConfig<T extends string> extends ValueConfig<T> {
url?: boolean;
regex?: RegExp;
constrainTo?: readonly T[];
ISODate?: boolean;
}

// Source: https://github.com/jquery-validation/jquery-validation/blob/c1db10a34c0847c28a5bd30e3ee1117e137ca834/src/core.js#L1349
const urlRegex =
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})).?)(?::\d{2,5})?(?:[/?#]\S*)?$/i;
const ISODateStringRegex =
/^\d{4}(-\d\d(-\d\d(T\d\d:\d\d(:\d\d)?(\.\d+)?(([+-]\d\d:\d\d)|Z)?)?)?)?$/i;

export class StringValue<T extends string = string>
implements SchemaValue<string>
Expand All @@ -27,7 +30,7 @@ export class StringValue<T extends string = string>
}

public validate(value: T) {
const {emptyAllowed, url, regex, constrainTo} = this.config;
const {emptyAllowed, url, regex, constrainTo, ISODate} = this.config;
const valueValidation = this.value.validate(value);
if (valueValidation) {
return valueValidation;
Expand Down Expand Up @@ -58,6 +61,16 @@ export class StringValue<T extends string = string>
return `value should be one of: ${values}.`;
}

if (
ISODate &&
!(
ISODateStringRegex.test(value) &&
new Date(value).toString() !== 'Invalid Date'
)
) {
return 'value is not a valid ISO8601 date string';
}

return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import {NoopPreprocessRequest} from '../../preprocess-request';
import {InsightAPIClient} from './insight-api-client';

describe('insight api client', () => {
const insightRequest = {
const configuration = {
accessToken: 'some token',
insightId: 'some insight id',
organizationId: 'some organization id',
url: 'https://some.platform.com',
};
const insightRequest = {
...configuration,
insightId: 'some insight id',
};
const exampleUserId = 'John Doe';

let client: InsightAPIClient;

Expand Down Expand Up @@ -192,12 +196,8 @@ describe('insight api client', () => {

describe('userActions', () => {
const userActionsRequest = {
...insightRequest,
ticketCreationDate: new Date().toISOString(),
numberSessionsBefore: 50,
numberSessionsAfter: 250,
maximumSessionInactivityMinutes: 60,
excludedCustomActions: ['unknown', 'irrelevant'],
...configuration,
userId: exampleUserId,
};

it('should call the platform endpoint with the correct arguments', async () => {
Expand All @@ -211,35 +211,10 @@ describe('insight api client', () => {
accessToken: userActionsRequest.accessToken,
method: 'POST',
contentType: 'application/json',
url: `${userActionsRequest.url}/rest/organizations/${userActionsRequest.organizationId}/insight/v1/configs/${userActionsRequest.insightId}/useractions`,
url: `${userActionsRequest.url}/rest/organizations/${userActionsRequest.organizationId}/machinelearning/user/actions`,
origin: 'insightApiFetch',
requestParams: {
ticketCreationDate: userActionsRequest.ticketCreationDate,
numberSessionsBefore: userActionsRequest.numberSessionsBefore,
numberSessionsAfter: userActionsRequest.numberSessionsAfter,
maximumSessionInactivityMinutes:
userActionsRequest.maximumSessionInactivityMinutes,
excludedCustomActions: userActionsRequest.excludedCustomActions,
},
});
});

it('should call the platform endpoint with the default values when not specified', async () => {
const callSpy = setupCallMock(true, 'some content');

await client.userActions({
...insightRequest,
ticketCreationDate: new Date().toISOString(),
});

expect(callSpy).toHaveBeenCalled();
const request = callSpy.mock.calls[0][0];
expect(request).toMatchObject({
requestParams: {
numberSessionsBefore: 50,
numberSessionsAfter: 50,
maximumSessionInactivityMinutes: 30,
excludedCustomActions: [],
objectId: userActionsRequest.userId,
},
});
});
Expand Down
40 changes: 39 additions & 1 deletion packages/headless/src/api/service/insight/insight-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PlatformClientCallOptions,
} from '../../platform-client';
import {BaseParam} from '../../platform-service-params';
import {InsightUserActionsRequest} from './user-actions/user-actions-request';

export interface InsightIdParam {
insightId: string;
Expand All @@ -17,6 +18,9 @@ export const baseInsightUrl = (req: InsightParam, path?: string) =>
req.insightId
}${path ?? ''}`;

export const baseInsightUserActionsUrl = (req: InsightUserActionsRequest) =>
`${req.url}/rest/organizations/${req.organizationId}/machinelearning/user/actions`;

export const baseInsightRequest = (
req: InsightParam,
method: HttpMethods,
Expand All @@ -39,12 +43,33 @@ export const baseInsightRequest = (
};
};

export const baseInsightUserActionRequest = (
req: InsightUserActionsRequest,
method: HttpMethods,
contentType: HTTPContentType
): Pick<
PlatformClientCallOptions,
'accessToken' | 'method' | 'contentType' | 'url' | 'origin'
> => {
validateInsightUserActionRequestParams(req);

const baseUrl = baseInsightUserActionsUrl(req);

return {
accessToken: req.accessToken,
method,
contentType,
url: baseUrl,
origin: 'insightApiFetch',
};
};

export const pickNonInsightParams = (req: InsightParam) => {
const {insightId, ...nonInsightParams} = pickNonBaseParams(req);
return nonInsightParams;
};

const validateInsightRequestParams = (req: InsightParam) => {
const validateConfigParams = (req: BaseParam) => {
if (!req.url) {
throw new Error("The 'url' attribute must contain a valid platform URL.");
}
Expand All @@ -58,9 +83,22 @@ const validateInsightRequestParams = (req: InsightParam) => {
"The 'accessToken' attribute must contain a valid platform access token."
);
}
};

const validateInsightRequestParams = (req: InsightParam) => {
validateConfigParams(req);
if (!req.insightId) {
throw new Error(
"The 'insightId' attribute must contain a valid Insight Panel configuration ID."
);
}
};

const validateInsightUserActionRequestParams = (
req: InsightUserActionsRequest
) => {
validateConfigParams(req);
if (!req.userId) {
throw new Error("The 'userId' attribute must contain a valid user ID.");
}
};
Original file line number Diff line number Diff line change
@@ -1,55 +1,22 @@
import {
baseInsightRequest,
InsightParam,
pickNonInsightParams,
} from '../insight-params';
import {BaseParam} from '../../../platform-service-params';
import {baseInsightUserActionRequest} from '../insight-params';

export type InsightUserActionsRequest = InsightParam &
TicketCreationDateParam &
NumberSessionsBeforeParam &
NumberSessionsAfterParam &
MaximumSessionInactivityMinutesParam &
ExcludedCustomActionsParam;
export type InsightUserActionsRequest = BaseParam & UserIdParam;

interface TicketCreationDateParam {
interface UserIdParam {
/**
* The ticket creation date in the [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format.
* Identifier of the user from which Clicked Documents are shown.
*/
ticketCreationDate: string;
}

interface NumberSessionsBeforeParam {
numberSessionsBefore?: number;
}

interface NumberSessionsAfterParam {
numberSessionsAfter?: number;
}

interface MaximumSessionInactivityMinutesParam {
maximumSessionInactivityMinutes?: number;
}

interface ExcludedCustomActionsParam {
excludedCustomActions?: string[];
userId: string;
}

export const buildInsightUserActionsRequest = (
req: InsightUserActionsRequest
) => {
const params = pickNonInsightParams(
req
) as Partial<InsightUserActionsRequest>;

return {
...baseInsightRequest(req, 'POST', 'application/json', '/useractions'),
...baseInsightUserActionRequest(req, 'POST', 'application/json'),
requestParams: {
ticketCreationDate: params.ticketCreationDate,
numberSessionsBefore: params.numberSessionsBefore ?? 50,
numberSessionsAfter: params.numberSessionsAfter ?? 50,
maximumSessionInactivityMinutes:
params.maximumSessionInactivityMinutes ?? 30,
excludedCustomActions: params.excludedCustomActions ?? [],
objectId: req.userId,
},
};
};
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
export interface InsightUserActionsResponse {
timeline?: UserActionTimeline;
}
import {UserActionType} from '../../../../features/insight-user-actions/insight-user-actions-state';

interface UserActionTimeline {
sessions: UserSession[];
}

interface UserSession {
start: Date;
end: Date;
actions: UserAction[];
export interface InsightUserActionsResponse {
value: Array<UserAction>;
}

type UserActionType = 'SEARCH' | 'CLICK' | 'VIEW' | 'CUSTOM';
interface UserAction {
actionType: UserActionType;
timestamp: Date;
raw: Record<string, string>;
searchHub?: string;
document?: string;
query?: string;
name: UserActionType;
time: string;
value: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {configuration} from '../../../app/common-reducers';
import {
fetchUserActions,
registerUserActions,
} from '../../../features/insight-user-actions/insight-user-actions-actions';
import {insightUserActionsReducer} from '../../../features/insight-user-actions/insight-user-actions-slice';
import {
buildMockInsightEngine,
MockedInsightEngine,
} from '../../../test/mock-engine-v2';
import {buildMockInsightState} from '../../../test/mock-insight-state';
import {
UserActions,
UserActionsOptions,
buildUserActions,
} from './headless-user-actions';

jest.mock(
'../../../features/insight-user-actions/insight-user-actions-actions'
);

describe('UserActions', () => {
let engine: MockedInsightEngine;

let options: UserActionsOptions;
let userActions: UserActions;

function initUserActions() {
userActions = buildUserActions(engine, {options});
}

const exampleUserId = 'John Doe';
const exampleTicketCreationDate = '2024-07-16T21:00:42.741Z';
const exampleExcludedCustomActions = ['badAction'];

beforeEach(() => {
options = {
ticketCreationDate: exampleTicketCreationDate,
excludedCustomActions: exampleExcludedCustomActions,
};
engine = buildMockInsightEngine(buildMockInsightState());

initUserActions();
});

it('initializes', () => {
expect(userActions).toBeTruthy();
});

it('it adds the correct reducers to the engine', () => {
expect(engine.addReducers).toHaveBeenCalledWith({
configuration,
insightuserActions: insightUserActionsReducer,
});
});

it('exposes a subscribe method', () => {
expect(userActions.subscribe).toBeTruthy();
});

it('registers and updates the state with the given options', () => {
expect(registerUserActions).toHaveBeenCalled();
expect(registerUserActions).toHaveBeenCalledWith({
ticketCreationDate: exampleTicketCreationDate,
excludedCustomActions: exampleExcludedCustomActions,
});
});

it('#fetchUserActions dispatches #fetchUserActions', () => {
userActions.fetchUserActions(exampleUserId);
expect(fetchUserActions).toHaveBeenCalled();
expect(fetchUserActions).toHaveBeenCalledWith(exampleUserId);
});
});
Loading

0 comments on commit b52953e

Please sign in to comment.