Skip to content

Commit

Permalink
feat: handle no matches [CORE-978] (#57)
Browse files Browse the repository at this point in the history
* wip: handle no matches

* chore: comments

* refactor: new handler

* wip: add full speak capabitilies

* chore: comments

* test: fix old

* test: new unit

* chore: rename test

* feat: handling a randomized reprompt (#59)

* feat: handling a randomized reprompt

* feat: unit tests for no match randomize

* feat: added randomize property to test object

* Update noMatch.unit.ts

refactor: removed randomize from one unit test to test undefined case instead
  • Loading branch information
leartgjoni-voiceflow authored Jun 1, 2020
1 parent e63819b commit d093344
Show file tree
Hide file tree
Showing 7 changed files with 248 additions and 20 deletions.
1 change: 1 addition & 0 deletions lib/constants/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export enum Storage {
STREAM_PAUSE = 'streamPause',
STREAM_FINISHED = 'streamFinished',
STREAM_TEMP = 'streamTemp',
NO_MATCHES_COUNTER = 'noMatchesCounter',
}

export enum Turn {
Expand Down
17 changes: 16 additions & 1 deletion lib/services/voiceflow/handlers/interaction.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { HandlerFactory } from '@voiceflow/client';

import { T } from '@/lib/constants';
import { S, T } from '@/lib/constants';

import { IntentRequest, Mapping, RequestType } from '../types';
import { addRepromptIfExists, formatName, mapSlots } from '../utils';
import CommandHandler from './command';
import NoMatchHandler from './noMatch';

type Choice = {
intent: string;
Expand All @@ -14,16 +15,19 @@ type Choice = {

type Interaction = {
elseId?: string;
noMatches?: string[];
nextIds: string[];
reprompt?: string;
interactions: Choice[];
randomize?: boolean;
};

const utilsObj = {
addRepromptIfExists,
formatName,
mapSlots,
commandHandler: CommandHandler(),
noMatchHandler: NoMatchHandler(),
};

export const InteractionHandler: HandlerFactory<Interaction, typeof utilsObj> = (utils) => ({
Expand All @@ -37,6 +41,9 @@ export const InteractionHandler: HandlerFactory<Interaction, typeof utilsObj> =
utils.addRepromptIfExists(block, context, variables);
context.trace.choice(block.interactions.map(({ intent }) => ({ name: intent })));

// clean up no matches counter on new interaction
context.storage.delete(S.NO_MATCHES_COUNTER);

// quit cycleStack without ending session by stopping on itself
return block.blockID;
}
Expand Down Expand Up @@ -69,6 +76,14 @@ export const InteractionHandler: HandlerFactory<Interaction, typeof utilsObj> =
// request for this turn has been processed, delete request
context.turn.delete(T.REQUEST);

// check for noMatches to handle
if (!nextId && utils.noMatchHandler.canHandle(block, context)) {
return utils.noMatchHandler.handle(block, context, variables);
}

// clean up no matches counter
context.storage.delete(S.NO_MATCHES_COUNTER);

return (nextId || block.elseId) ?? null;
},
});
Expand Down
38 changes: 38 additions & 0 deletions lib/services/voiceflow/handlers/noMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Context, Store } from '@voiceflow/client';
import _ from 'lodash';

import { S } from '@/lib/constants';

import { regexVariables, sanitizeVariables } from '../utils';

type Block = {
blockID: string;
noMatches?: string[];
randomize?: boolean;
};

export const NoMatchHandler = () => ({
canHandle: (block: Block, context: Context) => {
return Array.isArray(block.noMatches) && block.noMatches.length > (context.storage.get(S.NO_MATCHES_COUNTER) ?? 0);
},
handle: (block: Block, context: Context, variables: Store) => {
context.storage.produce((draft) => {
draft[S.NO_MATCHES_COUNTER] = draft[S.NO_MATCHES_COUNTER] ? draft[S.NO_MATCHES_COUNTER] + 1 : 1;
});

const speak = (block.randomize ? _.sample(block.noMatches) : block.noMatches?.[context.storage.get(S.NO_MATCHES_COUNTER) - 1]) || '';

const sanitizedVars = sanitizeVariables(variables.getState());
// replaces var values
const output = regexVariables(speak, sanitizedVars);

context.storage.produce((draft) => {
draft[S.OUTPUT] += output;
});
context.trace.speak(output);

return block.blockID;
},
});

export default () => NoMatchHandler();
13 changes: 2 additions & 11 deletions lib/services/voiceflow/handlers/speak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import _ from 'lodash';

import { F, S } from '@/lib/constants';

import { regexVariables } from '../utils';
import { regexVariables, sanitizeVariables } from '../utils';

export type Speak = {
audio?: string;
Expand All @@ -25,16 +25,7 @@ const SpeakHandler: HandlerFactory<Speak> = () => ({
speak = _.sample(block.random_speak);
}

// turn float variables to 2 decimal places
const sanitizedVars = Object.entries(variables.getState()).reduce<Record<string, any>>((acc, [key, value]) => {
if (_.isNumber(value) && !Number.isInteger(value)) {
acc[key] = value.toFixed(2);
} else {
acc[key] = value;
}

return acc;
}, {});
const sanitizedVars = sanitizeVariables(variables.getState());

if (_.isString(speak)) {
const output = regexVariables(speak, sanitizedVars);
Expand Down
13 changes: 13 additions & 0 deletions lib/services/voiceflow/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Context, Store } from '@voiceflow/client';
import { Slot } from 'ask-sdk-model';
import _ from 'lodash';

import { T } from '@/lib/constants';

Expand All @@ -18,6 +19,18 @@ export const regexVariables = (phrase: string, variables: Record<string, any>, m
return phrase.replace(/\{([a-zA-Z0-9_]{1,32})\}/g, (match, inner) => _replacer(match, inner, variables, modifier));
};

// turn float variables to 2 decimal places
export const sanitizeVariables = (variables: Record<string, any>) =>
Object.entries(variables).reduce<Record<string, any>>((acc, [key, value]) => {
if (_.isNumber(value) && !Number.isInteger(value)) {
acc[key] = value.toFixed(2);
} else {
acc[key] = value;
}

return acc;
}, {});

const _stringToNumIfNumeric = (str: string | null): number | string | null => {
const number = Number(str);

Expand Down
78 changes: 70 additions & 8 deletions tests/lib/services/voiceflow/handlers/interaction.unit.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect } from 'chai';
import sinon from 'sinon';

import { T } from '@/lib/constants';
import { S, T } from '@/lib/constants';
import { InteractionHandler } from '@/lib/services/voiceflow/handlers/interaction';
import { RequestType } from '@/lib/services/voiceflow/types';

Expand All @@ -27,12 +27,13 @@ describe('interaction handler unit tests', async () => {
const interactionHandler = InteractionHandler(utils as any);

const block = { blockID: 'block-id', interactions: [{ intent: 'one' }, { intent: 'two' }] };
const context = { trace: { choice: sinon.stub() }, turn: { get: sinon.stub().returns(null) } };
const context = { trace: { choice: sinon.stub() }, storage: { delete: sinon.stub() }, turn: { get: sinon.stub().returns(null) } };
const variables = { foo: 'bar' };

expect(interactionHandler.handle(block as any, context as any, variables as any, null as any)).to.eql(block.blockID);
expect(utils.addRepromptIfExists.args).to.eql([[block, context, variables]]);
expect(context.trace.choice.args[0][0]).to.eql([{ name: 'one' }, { name: 'two' }]);
expect(context.storage.delete.args).to.eql([[S.NO_MATCHES_COUNTER]]);
});

it('request type not intent', () => {
Expand All @@ -43,12 +44,13 @@ describe('interaction handler unit tests', async () => {
const captureHandler = InteractionHandler(utils as any);

const block = { blockID: 'block-id', interactions: [] };
const context = { trace: { choice: sinon.stub() }, turn: { get: sinon.stub().returns({ type: 'random' }) } };
const context = { trace: { choice: sinon.stub() }, storage: { delete: sinon.stub() }, turn: { get: sinon.stub().returns({ type: 'random' }) } };
const variables = { foo: 'bar' };

expect(captureHandler.handle(block as any, context as any, variables as any, null as any)).to.eql(block.blockID);
expect(utils.addRepromptIfExists.args).to.eql([[block, context, variables]]);
expect(context.trace.choice.args[0][0]).to.eql([]);
expect(context.storage.delete.args).to.eql([[S.NO_MATCHES_COUNTER]]);
});

describe('request type is intent', () => {
Expand Down Expand Up @@ -80,13 +82,16 @@ describe('interaction handler unit tests', async () => {
commandHandler: {
canHandle: sinon.stub().returns(false),
},
noMatchHandler: {
canHandle: sinon.stub().returns(false),
},
};

const interactionHandler = InteractionHandler(utils as any);

const block = { blockID: 'block-id', interactions: [{ intent: 'intent1' }, { intent: 'intent2' }] };
const request = { type: RequestType.INTENT, payload: { intent: { name: 'random-intent' } } };
const context = { turn: { get: sinon.stub().returns(request), delete: sinon.stub() } };
const context = { turn: { get: sinon.stub().returns(request), delete: sinon.stub() }, storage: { delete: sinon.stub() } };
const variables = { foo: 'bar' };

expect(interactionHandler.handle(block as any, context as any, variables as any, null as any)).to.eql(null);
Expand All @@ -100,18 +105,54 @@ describe('interaction handler unit tests', async () => {
commandHandler: {
canHandle: sinon.stub().returns(false),
},
noMatchHandler: {
canHandle: sinon.stub().returns(false),
},
};

const interactionHandler = InteractionHandler(utils as any);

const block = { blockID: 'block-id', elseId: 'else-id', interactions: [{ intent: 'intent1' }, { intent: 'intent2' }] };
const request = { type: RequestType.INTENT, payload: { intent: { name: 'random-intent' } } };
const context = { turn: { get: sinon.stub().returns(request), delete: sinon.stub() } };
const context = { turn: { get: sinon.stub().returns(request), delete: sinon.stub() }, storage: { delete: sinon.stub() } };
const variables = { foo: 'bar' };

expect(interactionHandler.handle(block as any, context as any, variables as any, null as any)).to.eql(block.elseId);
});

it('no choice with noMatches', () => {
const nextId = 'next-id';
const noMatches = ['speak1', 'speak2', 'speak3'];

const utils = {
formatName: sinon.stub().returns(false),
commandHandler: {
canHandle: sinon.stub().returns(false),
},
noMatchHandler: {
canHandle: sinon.stub().returns(true),
handle: sinon.stub().returns(nextId),
},
};

const interactionHandler = InteractionHandler(utils as any);

const block = {
blockID: 'block-id',
interactions: [{ intent: 'intent1' }, { intent: 'intent2' }],
noMatches,
};
const request = { type: RequestType.INTENT, payload: { intent: { name: 'random-intent' } } };
const context = { turn: { get: sinon.stub().returns(request), delete: sinon.stub() } };
const variables = { foo: 'bar' };

expect(interactionHandler.handle(block as any, context as any, variables as any, null as any)).to.eql(nextId);
expect(utils.formatName.args).to.eql([[block.interactions[0].intent], [block.interactions[1].intent]]);
expect(context.turn.delete.args).to.eql([[T.REQUEST]]);
expect(utils.noMatchHandler.canHandle.args).to.eql([[block, context]]);
expect(utils.noMatchHandler.handle.args).to.eql([[block, context, variables]]);
});

it('choice without mappings', () => {
const intentName = 'random-intent';

Expand All @@ -120,13 +161,20 @@ describe('interaction handler unit tests', async () => {
commandHandler: {
canHandle: sinon.stub().returns(false),
},
noMatchHandler: {
canHandle: sinon.stub().returns(false),
},
};

const interactionHandler = InteractionHandler(utils as any);

const block = { blockID: 'block-id', elseId: 'else-id', interactions: [{ intent: 'random-intent ' }], nextIds: ['id-one'] };
const request = { type: RequestType.INTENT, payload: { intent: { name: intentName } } };
const context = { trace: { debug: sinon.stub() }, turn: { get: sinon.stub().returns(request), delete: sinon.stub() } };
const context = {
trace: { debug: sinon.stub() },
turn: { get: sinon.stub().returns(request), delete: sinon.stub() },
storage: { delete: sinon.stub() },
};
const variables = { foo: 'bar' };

expect(interactionHandler.handle(block as any, context as any, variables as any, null as any)).to.eql(block.nextIds[0]);
Expand All @@ -141,6 +189,9 @@ describe('interaction handler unit tests', async () => {
commandHandler: {
canHandle: sinon.stub().returns(false),
},
noMatchHandler: {
canHandle: sinon.stub().returns(false),
},
};

const interactionHandler = InteractionHandler(utils as any);
Expand All @@ -152,7 +203,11 @@ describe('interaction handler unit tests', async () => {
nextIds: ['id-one', 'id-two'],
};
const request = { type: RequestType.INTENT, payload: { intent: { name: intentName } } };
const context = { trace: { debug: sinon.stub() }, turn: { get: sinon.stub().returns(request), delete: sinon.stub() } };
const context = {
trace: { debug: sinon.stub() },
turn: { get: sinon.stub().returns(request), delete: sinon.stub() },
storage: { delete: sinon.stub() },
};
const variables = { foo: 'bar' };

expect(interactionHandler.handle(block as any, context as any, variables as any, null as any)).to.eql(block.nextIds[1]);
Expand All @@ -168,6 +223,9 @@ describe('interaction handler unit tests', async () => {
commandHandler: {
canHandle: sinon.stub().returns(false),
},
noMatchHandler: {
canHandle: sinon.stub().returns(false),
},
mapSlots: sinon.stub().returns(mappedSlots),
};

Expand All @@ -180,7 +238,11 @@ describe('interaction handler unit tests', async () => {
nextIds: ['id-one'],
};
const request = { type: RequestType.INTENT, payload: { intent: { name: intentName, slots: { foo2: 'bar2' } } } };
const context = { trace: { debug: sinon.stub() }, turn: { get: sinon.stub().returns(request), delete: sinon.stub() } };
const context = {
trace: { debug: sinon.stub() },
turn: { get: sinon.stub().returns(request), delete: sinon.stub() },
storage: { delete: sinon.stub() },
};
const variables = { merge: sinon.stub() };

expect(interactionHandler.handle(block as any, context as any, variables as any, null as any)).to.eql(block.nextIds[0]);
Expand Down
Loading

0 comments on commit d093344

Please sign in to comment.