In our previous chapters we refactored a chatbot into logic that returns which action to run, and the actual actions.
In this chapter we'll add a new scenario to our chatbot, and learn how to use Prague to elimininate up a lot of repetitive code, making our remaining code more expressive and future-proof.
Let's consider a new scenario for our chatbot:
if a user asks for a recipe
if they provided a recipe name
look it up
if we find it
return a "show recipe" action reference
else
return a "coudln't find recipe" action reference
else
tell them they need to provide a name
We will make use of an NLP service to interpret the user's utterance, and a recipe service to retrieve the recipe:
const { nlpService, recipeService } = require('./services');
async nlpService(modelId, text) => {
intents: {
name: string,
score: number
}[], // sorted by score descending. Always exists, may be empty array.
entities: {
name: string,
value: string,
score: number
}[] // sorted by score descending. Always exists, may be empty array.
} (throws if model is invalid or there is a server error)
async recipeService(name) => {
recipes: {
name: string;
ingredients: string[],
instructions: string[]
}[] // Always exists, may be empty array.
} (throws if there is a server error)
We'll add three new actions, a model ID, and a function that returns either null
/undefined
or an ActionReference
.
const actions = new ActionReferences({
// all the existing functions plus:
showRecipe(recipe) { ... },
noSuchRecipe(name) { ... },
missingReceipeName() { ... },
});
const modelId = "XXXX_YYYY_ZZZZ";
const recipe = async text => {
let nlpResponse = await nlpService(modelId, text);
if (nlpResponse.intents.length === 0 || nlpResponse.intents[0].name !== "getRecipe")
return null;
const recipeName = nlpResponse.entities.find(recipe => recipe.name === "recipeName");
if (!recipeName)
return actions.reference.missingRecipeName();
recipeResponse = await recipeService(recipeName);
return recipeResponse.recipes.length === 0
? actions.reference.noSuchRecipe(recipeName);
: actions.reference.showRecipe(recipeResponse.recipes[0]);
}
Now let's add this function to our existing botLogic
:
const botLogic = async req => {
const artistResult = /play (.*)/i.exec(req.text);
if (artistResult)
return actions.reference.playArtist(artistResult[1]);
const randomResult = /play/i.test(req.text);
if (randomResult)
return actions.reference.playRandom();
r = await recipe(req.text);
if (r)
return r;
}
Prague is designed to clean up repetitive logic like this. Let's introduce the first
function:
const botLogic = req => first(
text => {
const artistResult = /play (.*)/i.exec(req.text);
if (artistResult)
return actions.reference.playArtist(artistResult[1]);
},
text => {
const randomResult = /play/i.test(req.text);
if (randomResult)
return actions.reference.playRandom();
},
text => {
let r = /is my (.*) cured/i.exec(req.text);
if (r)
return actions.reference.healthStatus(r[1]);
},
async text => {
r = await recipe(req.text);
if (r)
return r;
}
)(req.text);
first
is a higher-order function, which is to say that it takes functions as arguments and returns a new function. This new function takes its own set of arguments and calls each function argument in order, with those arguments. If that function retuns a result other than null
/undefined
, it returns that result. Otherwise it tries the next function. If all of the functions return null
/undefined
, it does too.
Like all Prague functions, the functions that first
takes as arguments can return either a result or a Promise
of a result, which is why that last argument can be an async
function.
This new version of botLogic
is just as repetitive as what we started with. Let's fix that.
Conveniently, recipe
returns null
when the text doesn't match, so we can replace:
async text => {
let r = await recipe(text);
if (r)
return r;
}
with:
text => recipe(text),
and that can be simplified further to:
recipe,
The other tests come down to the pattern if this function returns a result, transform that result into a different result and return that, otherwise return something else. Prague has a function called match
for this common pattern. We'll replace:
text => {
const artistResult = /play (.*)/i.exec(req.text);
if (artistResult)
return actions.reference.playArtist(artistResult[1]);
},
with:
text => match(
text => /play (.*)/i.exec(text),
artistResult => actions.reference.playArtist(artistResult[1])
)(text),
match
takes three functions which we'll call getResult
, onResult
, onNull
(optional), and returns a new function. This new function takes its own arguments and calls getResult
with them. If the result is null
/undefined
it calls onNull
and returns that result (if onNull
is missing, as it is here, it returns null
). Otherwise it calls onResult
with getResult
's result, and returns that result.
We can further simplified this to:
match(
text => /play (.*)/i.exec(text),
artistResult => actions.reference.playArtist(artistResult[1])
),
Unfortunately we can't simplify text => /foo/.exec(text)
to /foo/.exec
because JavaScript, so Prague supplies a helper called re
to eliminate just that last bit of duplication:
match(
re(/play (.*)/i),
artistResult => actions.reference.playArtist(artistResult[1])
),
With all that, we can greatly simplify botLogic
:
const botLogic = req => first(
match(
re(/play (.*)/i),
artistResult => actions.reference.playArtist(artistResult[1])
},
match(
re(/play/i),
() => actions.reference.playRandom()
},
recipe,
)(req.text);
Eliminating all the repetitive code makes botLogic
shorter. More importantly, it's also more expressive. Prague code looks almost like a declarative markup language.
Now let's return to our recipe
function. Could it be recoded with Prague?
Well... you could. Using two more Prague functions tube
and onlyContinueIf
we end up with:
const recipe = tube(
text => nlpService(modelId, text),
onlyContinueIf(nlpResponse => nlpResponse.intents.length === 0 || nlpResponse.intents[0].name !== "getRecipe"),
match(
nlpResponse => nlpResponse.entities.find(recipe => recipe.name === "recipeName"),
recipeName => match(
() => recipeService(recipeName),
recipeResponse => actions.reference.showRecipe(recipeResponse.recipes[0]),
() => actions.reference.noSuchRecipe(recipeName)
)(),
() => actions.reference.missingRecipeName()
)
)
In this case even an experienced Praguerammer might conclude that the original was clearer. Use the right tool for the job. Prague is not meant to replace all your JavaScript logic. You will quickly gain a sense for when Prague makes your code clearer, and when it makes it more opaque. A good rule of thumb is that Prague is helpful when your code seems unecessarily verbose and repetitive.
But since we went to the effort, let's at least see how this code works.
tube
takes as arguments a sequence of functions, and returns a new function. This new function calls each supplied function in order. Its arguments become the arguments to the first function. The result of the first becomes the argument of the second, and so on. If any returns null
/undefined
then the new function does too, otherwise it returns the result of the last function. The idea of tube
is a series of transforms that fails as soon as one of the transforms fail. In fact, we call Prague functions "transform"s.
You may be wondering where the text
argument to recipe
went. Remember that tube(...)
returns a function. The argument to that function is the argument to the first transform, which is text
.
onlyContinueIf
takes a predicate as an argument and returns a new function which evaluates that predicate with its argument. If the predicate is "truthy" it returns its result, otherwise it returns null
. onlyContinueIf
is used as a quick way to short circuit a tube
if a condition is not met. In this case we're bailing out early if our NLP service doesn't think the user is asking for a recipe.
You already know about match
. In this case we nest two match
es.
Our previous bot
looks like this:
const bot = (req, res) => botLogic(req).then(actions.doAction(res));
But it would be more Praguematic to see doAction
as a final transformation:
const bot = (req, res) => tube(
botLogic,
actions.doAction(res)
)(req)
Most Prague apps will look like this, so ActionReferences
provides a helper:
const bot = (req, res) => actions.run(botLogic, res)(req);
As it stands, botLogic
is a little too tightly-coupled in how it handles pattern matching.
Let's say we decide to upgrade our music service pattern-matching from regular expressions to an nlp model like we used for recipes.
Intuitively we'd like to be able to change:
match(
re(/play (.*)/i),
artistResult => actions.reference.playArtist(artistResult[1])
},
to something like:
match(
/* some sort of call to the nlp service */,
artistResult => actions.reference.playArtist(artistResult[1])
},
... but we can't, because re(...)
returns a very different result than the nlp service. We made the mistake of writing our match
in such a way that it depended on the matcher implementation.
Let's refactor our match to be more agnostic about the implementation of its matcher:
match(
/* function that returns null | string */,
artist => actions.reference.playArtist(artist)
},
Much better. Now we can reimplement our regular expression matcher to meet this new interface using re
's optional second "which capture group" parameter:
const rePlayArtist = re(/play (.*)/i, 1),
Next, we can use tube
to create a helper that easily transforms the result of an nlp service for a given model, intent, and entity...
const nlp = (model, intentName, entityName) => text => tube(
nlpService(model, text),
r => r.intents.find(intent => intent.name === intentName) && (
!entityName || r.entities.find(entity => entity.name === entityName)
)
);
... and use that helper to create an nlp version of rePlayArtist
:
const nlpPlayArtist = nlp(musicModel, 'playArtist', 'artist');
Now we can easily replace rePlayArtist
with nlpPlayArtist
. More interestingly, we could dynamically choose rePlayArtist
when we're offline and nlpPlayArtist
when we're online:
const playArtist === botState.online ? rePlayArtist : nlpPlayArtist;
Do a similar thing with playRandom
and our revised bot code looks like this:
const botLogic = req => first(
match(
playArtist,
artist => actions.reference.playArtist(artist)
},
match(
playRandom,
() => actions.reference.playRandom()
}, recipe,
recipe,
)(req.text);
A fantastic result of this is that playArtist
and playRandom
have become independently testable. On the flip side, we could mock each of those functions to do even more types of testing.
By refactoring our match
es, botLogic
has become less tightly coupled and thus more flexible and testable.
Calling a Prague transform has three potential outcomes:
- It can return a result
- It can return
null
/undefined
, usually interpreted as "this transform didn't apply" - It can throw an exception
It's important to distinguish the last two. null
/undefined
is not pejorative. Nothing unexpected happened and nothing "failed". The transform simply didn't apply. You can see this in action in recipe
- it returns null
/undefined
to indicate that the user wasn't asking for a recipe. On the other hand, if either of our fetch
calls fail, it throws an exception, which you can handle:
const bot = (req, res) => actions.run(botLogic, res)(req)
.catch(err => {
console.log("Exception", err);
});
(Thinking about recipe
, a more user-friendly approach would probably be to return an action letting the user know that there was an unexpected error. This exercise is left for the reader).
In this chapter we added a new scenario to our chatbot, and learned how to use Prague to elimininate up a lot of repetitive code, making our remaining code more expressive and future-proof. Just as importantly, we also learned that Prague isn't the right tool for every job.
In the next chapter we'll learn about dealing with multiple ambiguous matches with scoring.