Skip to content

Latest commit

 

History

History
388 lines (284 loc) · 13.3 KB

2.combining.md

File metadata and controls

388 lines (284 loc) · 13.3 KB

Combining Functions with Prague

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.

A recipe... for success!

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;
}

Cleaning up code with Prague

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.

Eliminating repetition

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.

When not to use Prague

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 matches.

Prague and bot

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);

Coding for the future

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 matches, botLogic has become less tightly coupled and thus more flexible and testable.

Errors and Prague

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).

Conclusion

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.

Next

In the next chapter we'll learn about dealing with multiple ambiguous matches with scoring.