Create a suggestion step

Currently, when a user selects a time slot that's not available on the calendar, the agent simply tells the user that the selected time slot isn't available. This approach results in a bad user experience because the agent relies on the user to guess an available time slot without providing any helpful information. A better approach is to have the agent suggest one or two available time slots based on the user's preference.

In this tutorial, for the sake of simplicity, we use a dummy approach when making suggestions—if a user selects a time slot that's not available, our agent simply suggests a new time slot that's at the same hour on the next day. (However, you need to implement a more sophisticated approach for your real world application.)

Figure 7. The happy path contains a detour: suggestion.

Understand what to build

Check out the following sample dialog that now contains the suggestion step:

  • User: I need to fix my bike.
  • Agent: Okay, I can help you with that. What day do you want to come in?
  • User: Today.
  • Agent: And what time works for you?
  • User: 3 PM.
  • Agent: Sorry, we're booked on Wednesday, August 1 at 3 PM. But we have a spot the same time the next day. Do you want to book it?
  • User: Yes.
  • Agent: Great, Thursday, August 2 at 3 PM. Did I get that right?
  • User: Yes.
  • Agent: Got it. I have your appointment scheduled on Thursday, August 2 at 3 PM. See you soon. Good-bye.

We can think of the suggestion step as a detour from the happy path; after completing the suggestion step, the flow returns to the previous point of departure on the happy path and continues with the rest of the steps in the path. To create this setup, we use contexts to manage the context domains and the flow of conversation (see Figure 8).

Figure 8. The context MakeAppointment-suggestion lets users take a detour from the happy path.

Separation and isolation of intents

We now need to introduce a new context domain that's separated and isolated from the original context domain. In this new context domain, intents can participate in the matching of user utterances without being interfered with the intents from the original context domain. This separation and isolation of intents allows us to create a number of intents that listen for the same training phrases under different contexts.

For example, in the sample dialog, the agent suggests a new time slot and asks if the user wants to accept it:

Sorry, we're booked on Wednesday, August 1 at 3 PM. But we have a spot the same time the next day. Do you want to book it?

In the next turn, the agent expects the user's response to be either yes or no. This means that we need to create another pair of yes and no follow-up intents to handle the response. However, notice that the Make Appointment intent already has yes and no follow-up intents (see Figure 9).

Figure 9. The intents list shows the Make Appointment intent and its yes and no follow-up intents, Make Appointment - yes and Make Appointment - no.

We can't just add another pair of yes and no follow-up intents to the Make Appointment intent because it would create a situation where two yes follow-up intents (or two no follow-up intents) compete for matching in the same turn (see Figure 10). For this reason, we need to use contexts to separate and isolate the two sets of yes and no follow-up intents (see Figure 11).

Figure 10. Without the separation and isolation of intents, when the user says yes, the agent cannot tell whether the user agrees to the confirmation or the suggestion.

Figure 11. With the help of two context domains, when the user says yes, the agent now can distinguish whether the user agrees to the confirmation or the suggestion.

The agent currently uses the context MakeAppointment-followup to allow the Make Appointment - yes and Make Appointment - no intents to become participants in the matching of user utterances in the following turn. We now need to introduce another context called MakeAppointment-suggestion to create a new context domain for the suggestion step (see Figure 8).

How to build it

To add a suggestion step to the path, we need the following setup:

  • When a user selects an unavailable time slot, the Make Appointment intent switches off the context MakeAppointment-followup and switches on the context MakeAppointment-suggestion.
  • When the context MakeAppointment-suggestion is active, a new set of yes and no follow-up intents (Make Appointment - Suggestion - no and Make Appointment - Suggestion - yes) participates in matching.

Implementing the suggestion step requires us to make the following changes to our agent:

  • Create a new pair of yes and no follow-up intents and adjust their input and output contexts using the console.
  • Update the fulfillment code to enable suggestion and manage the switching of contexts.

Figure 12. Flowchart showing the context MakeAppointment-suggestion and the new pair of yes and no follow-up intents in the bottom left corner.

Create a new pair of yes and no follow-up intents

First, we create a new pair of yes and no follow-up intents, Make Appointment - Suggestion - no and Make Appointment - Suggestion - yes. We set their input context to be MakeAppointment-suggestion and remove their default input context MakeAppointment-followup.

When the user accepts the new time slot, the Make Appointment - Suggestion - yes intent is matched—its fulfillment function then returns the flow to the happy path (see the Update the fulfillment code subsection). However, if the user rejects the suggested time slot, the Make Appointment - Suggestion - no intent cancels the scheduling process.

To implement the setup above, do the following:

  1. Click on Intents in the left navigation.
  2. Hover over the Make Appointment intent.
  3. Click Add follow-up intent and select yes. (A new intent Make Appointment - yes-2 is created.)
  4. Click on Make Appointment - yes-2.
  5. Rename the intent to Make Appointment - Suggestion - yes.
  6. In the Contexts section, delete all the input context from the Make Appointment - Suggestion - yes intent.
  7. Add MakeAppointment-Suggestion to Add input context.
  8. Delete MakeAppointment-Suggestion from the output context field.

  9. Scroll down to the Fulfillment section and toggle on Enable webhook call for this intent.

  10. Click SAVE.

  11. Click on Intents in the left navigation.

  12. Hover over the Make Appointment intent.

  13. Click Add follow-up intent and select no. (A new intent Make Appointment - no-2 is created.)

  14. Click on Make Appointment - no-2.

  15. Rename the intent to Make Appointment - Suggestion - no.

  16. Delete all the input context from the Make Appointment - Suggestion - no intent.

  17. Add MakeAppointment-Suggestion to Add input context.

  18. Set the lifespan of the output context MakeAppointment-Suggestion to 0.

  19. In the Responses section's Text response table, copy and paste the following response:

    Okay, I canceled your appointment. Is there anything else I can help you with?

  20. Click SAVE.

  21. Click on Intents in the left navigation.

  22. Click on Make Appointment.

  23. In the Contexts section, delete the output context MakeAppointment-followup-2.

  24. Click SAVE.

Update the fulfillment code

Next, we update the fulfillment code to make changes in the checkAppointment() function. When a user selects an unavailable time slot, the checkAppointment() function switches on the context MakeAppointment-suggestion, suggests a new time slot, and returns the following response:

Sorry, we're booked on Wednesday, August 1 at 3 PM. But we have a spot the same time the next day. Do you want to book it?

We also add a new function called suggestAppointment(), which adjusts the contexts to return the flow to the happy path.

To update the fulfillment code, do the following:

  1. Click Fulfillment on the navigation bar to go to the fulfillment page.
  2. Go to the index.js tab of the inline editor.
  3. From the existing code, copy the lines that contain your Google Calendar credentials (which look like the code below) and save the lines in a text file temporarily:

    const calendarId = 'your-google-calendar-ID-here@group.calendar.google.com';
    const serviceAccount = {
      "type": "service_account",
      "project_id": "marysbikeshop-...",
      "private_key_id": "...",
      "private_key": "-----BEGIN PRIVATE KEY-----\n …",
      "client_email": "bike-shop-calendar@<project-id>.iam.gserviceaccount.com",
      "client_id": "...",
      "auth_uri": "https://accounts.google.com/o/oauth2/auth",
      "token_uri": "https://accounts.google.com/o/oauth2/token",
      "auth_provider_x509_cert_url": "https://www.googleapis.com...",
      "client_x509_cert_url": "https://www.googleapis.com/robot/v..."
    };
    
  4. Copy and paste the code below to the index.js tab of the inline editor:

    /**
     * Copyright 2017 Google Inc. All Rights Reserved.
     *
     * Licensed under the Apache License, Version 2.0 (the "License");
     * you may not use this file except in compliance with the License.
     * You may obtain a copy of the License at
     *
     *      http://www.apache.org/licenses/LICENSE-2.0
     *
     * Unless required by applicable law or agreed to in writing, software
     * distributed under the License is distributed on an "AS IS" BASIS,
     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     * See the License for the specific language governing permissions and
     * limitations under the License.
     */
    
    'use strict';
    
    const functions = require('firebase-functions');
    const {google} = require('googleapis');
    const {WebhookClient} = require('dialogflow-fulfillment');
    
    // Enter your calendar ID and service account JSON below.
    // See https://github.com/dialogflow/bike-shop/blob/master/README.md#calendar-setup
    const calendarId = '<INSERT CALENDAR ID HERE>'; // Example: 6ujc6j6rgfk02cp02vg6h38cs0@group.calendar.google.com
    const serviceAccount = {}; // The JSON object looks like: { "type": "service_account", ... }
    
    // Set up Google Calendar service account credentials
    const serviceAccountAuth = new google.auth.JWT({
      email: serviceAccount.client_email,
      key: serviceAccount.private_key,
      scopes: 'https://www.googleapis.com/auth/calendar'
    });
    
    const calendar = google.calendar('v3');
    process.env.DEBUG = 'dialogflow:*'; // It enables lib debugging statements
    
    const timeZone = 'America/Los_Angeles';  // Change it to your time zone
    
    exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
      const agent = new WebhookClient({ request, response });
    
      // This function receives the date and time values from the context 'MakeAppointment-followup'
      // and calls the createCalendarEvent() function to mark the specified time slot on Google Calendar.
      function makeAppointment (agent) {
        // Get the context 'MakeAppointment-followup'
        const context = agent.context.get('makeappointment-followup');
        // This variable needs to hold an instance of Date object that specifies the start time of the appointment.
        let dateTimeStart;
        // If the context has 'sugguested_time' parameter, use this value instead of using 'date' and 'time' parameters.
        if (context.parameters.hasOwnProperty('suggested_time') && context.parameters.suggested_time !== '') {
          // Construct an instance of Date object using the 'suggested_time' parameter.
          dateTimeStart = new Date(context.parameters.suggested_time);
        } else {
          // The funciton convertTimestampToDate() returns an instance of Date object that is constructed from 'date' and 'time' parameters.
          dateTimeStart = convertTimestampToDate(context.parameters.date, context.parameters.time);
        }
        // This variable holds the end time of the appointment, which is calculated by adding an hour to the start time.
        const dateTimeEnd = addHours(dateTimeStart, 1);
        // Convert the Date object into human-readable strings.
        const appointmentTimeString = getLocaleTimeString(dateTimeStart);
        const appointmentDateString = getLocaleDateString(dateTimeStart);
        // Delete the context 'MakeAppointment-followup'; this is the final step of the path.
        agent.context.delete('makeappointment-followup');
        // The createCalendarEvent() function checks the availability of the time slot and marks the time slot on Google Calendar if the slot is available.
        return createCalendarEvent(dateTimeStart, dateTimeEnd).then(() => {
          agent.add(`Got it. I have your appointment scheduled on ${appointmentDateString} at ${appointmentTimeString}. See you soon. Good-bye.`);
        }).catch(() => {
          agent.add(`Sorry, something went wrong. I couldn't book the ${appointmentDateString} at ${appointmentTimeString}. Is there anything else I can help you with?`);
        });
      }
    
      // This function receives the date and time values from the context 'MakeAppointment-followup'
      // and calls the checkCalendarAvailablity() function to check the availability of the time slot on Google Calendar.
      function checkAppointment (agent) {
        // This variable needs to hold an instance of Date object that specifies the start time of the appointment.
        const dateTimeStart = convertTimestampToDate(agent.parameters.date, agent.parameters.time);
        // This variable holds the end time of the appointment, which is calculated by adding an hour to the start time.
        const dateTimeEnd = addHours(dateTimeStart, 1);
        // Convert the Date object into human-readable strings.
        const appointmentTimeString = getLocaleTimeString(dateTimeStart);
        const appointmentDateString = getLocaleDateString(dateTimeStart);
        // The checkCalendarAvailablity() function checks the availability of the time slot.
        return checkCalendarAvailablity(dateTimeStart, dateTimeEnd).then(() => {
          // The time slot is available.
          // The function returns a response that asks for the confirmation of the date and time.
          agent.add(`Okay, ${appointmentDateString} at ${appointmentTimeString}. Did I get that right?`);
        }).catch(() => {
          // The time slot is not available.
          // The function suggests a new time slot.
          agent.add(`Sorry, we're booked on ${appointmentDateString} at ${appointmentTimeString}. But we have a spot the same time the next day. Do you want to book it?`);
          // Create a new time slot, which is the next day the same time.
          dateTimeStart.setDate(dateTimeStart.getDate() + 1);
          // Include the newly suggested time slot to the parameter 'suggested_time' of the context 'MakeAppointment-suggestion'
          agent.context.set({'name': 'makeappointment-suggestion', 'lifespan': 3, 'parameters': {'suggested_time': dateTimeStart}});
          // Delete the context 'MakeAppointment-followup' to return the flow of conversation to the beginning.
          agent.context.delete('makeappointment-followup');
        });
      }
    
      // This function is called when the user agrees to the newly suggested time slot.
      // The function receives the time slot value from the parameter 'suggested_time' of the context 'MakeAppointment-suggestion'
      // and passes the value to the parameter 'suggested_time' of the context 'MakeAppointment-followup'.
      function suggestAppointment (agent) {
        // Get the context 'MakeAppointment-suggestion'
        const suggestContext = agent.context.get('makeappointment-suggestion');
        // Construct an instance of Date object using the 'suggested_time' parameter.
        const dateTimeStart = new Date(suggestContext.parameters.suggested_time);
        // Convert the Date object into human-readable strings.
        const appointmentTimeString = getLocaleTimeString(dateTimeStart);
        const appointmentDateString = getLocaleDateString(dateTimeStart);
        // Asks for the confirmation of the date and time values.
        agent.add(`Great. ${appointmentDateString} at ${appointmentTimeString}. Did I get that right?`);
        // Include the newly suggested time slot to the parameter 'suggested_time' of the context 'MakeAppointment-followup'.
        agent.context.set({'name': 'makeappointment-followup', 'lifespan': 3, 'parameters': {'suggested_time': dateTimeStart}});
        // Delete the context 'MakeAppointment-suggestion'.
        agent.context.delete('makeappointment-suggestion');
        return;
      }
    
      // Mapping of the functions to the agent's intents.
      let intentMap = new Map();
      intentMap.set('Make Appointment', checkAppointment);
      intentMap.set('Make Appointment - yes', makeAppointment);
      intentMap.set('Make Appointment - Suggestion - yes', suggestAppointment);
      agent.handleRequest(intentMap);
    });
    
    // This function checks for the availability of the time slot, which starts at 'dateTimeStart' and ends at 'dateTimeEnd'.
    // 'dateTimeStart' and 'dateTimeEnd' are instances of a Date object.
    function checkCalendarAvailablity (dateTimeStart, dateTimeEnd) {
      return new Promise((resolve, reject) => {
        calendar.events.list({
          auth: serviceAccountAuth, // List events for time period
          calendarId: calendarId,
          timeMin: dateTimeStart.toISOString(),
          timeMax: dateTimeEnd.toISOString()
        }, (err, calendarResponse) => {
          // Check if there is an event already on the Bike Shop Calendar
          if (err || calendarResponse.data.items.length > 0) {
            reject(err || new Error('Requested time conflicts with another appointment'));
          }else {
            resolve(calendarResponse);
          }
        });
      });
    }
    
    // This function marks the time slot on Google Calendar. The time slot on the calendar starts at 'dateTimeStart' and ends at 'dateTimeEnd'.
    // 'dateTimeStart' and 'dateTimeEnd' are instances of a Date object.
    function createCalendarEvent (dateTimeStart, dateTimeEnd) {
      return new Promise((resolve, reject) => {
        calendar.events.list({
          auth: serviceAccountAuth, // List events for time period
          calendarId: calendarId,
          timeMin: dateTimeStart.toISOString(),
          timeMax: dateTimeEnd.toISOString()
        }, (err, calendarResponse) => {
          // Check if there is an event already on the Bike Shop Calendar
          if (err || calendarResponse.data.items.length > 0) {
            reject(err || new Error('Requested time conflicts with another appointment'));
          } else {
            // Create event for the requested time period
            calendar.events.insert({ auth: serviceAccountAuth,
              calendarId: calendarId,
              resource: {summary: 'Bike Appointment',
                start: {dateTime: dateTimeStart},
                end: {dateTime: dateTimeEnd}}
            }, (err, event) => {
              err ? reject(err) : resolve(event);
            }
            );
          }
        });
      });
    }
    
    // A helper function that receives Dialogflow's 'date' and 'time' parameters and creates a Date instance.
    function convertTimestampToDate(date, time){
      // Parse the date, time, and time zone offset values from the input parameters and create a new Date object
      return new Date(Date.parse(date.split('T')[0] + 'T' + time.split('T')[1].split('-')[0] + '-' + time.split('T')[1].split('-')[1]));
    }
    
    // A helper function that adds the integer value of 'hoursToAdd' to the Date instance 'dateObj' and returns a new Data instance.
    function addHours(dateObj, hoursToAdd){
      return new Date(new Date(dateObj).setHours(dateObj.getHours() + hoursToAdd));
    }
    
    // A helper function that converts the Date instance 'dateObj' into a string that represents this time in English.
    function getLocaleTimeString(dateObj){
      return dateObj.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true, timeZone: timeZone });
    }
    
    // A helper function that converts the Date instance 'dateObj' into a string that represents this date in English.
    function getLocaleDateString(dateObj){
      return dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: timeZone });
    }
    
  5. In the index.js tab of the inline editor, replace the following lines with the previously saved lines (from Step 3) that contain your Google Calendar credentials:

    // Enter your calendar ID and service account JSON below.
    // See https://github.com/dialogflow/bike-shop/blob/master/README.md#calendar-setup
    const calendarId = '<INSERT CALENDAR ID HERE>'; // Example: 6ujc6j6rgfk02cp02vg6h38cs0@group.calendar.google.com
    const serviceAccount = {}; // The JSON object looks like: { "type": "service_account", ... }
    
  6. Click DEPLOY.

  7. Test the update using the simulator in the Dialogflow console.

How the code works

We can now control the flow of conversation using the contexts MakeAppointment-followup and MakeAppointment-suggestion. Let's take a look at the checkAppointment() function's final instruction set that handles the tasks of suggesting a new time slot and managing the contexts:

function checkAppointment (agent) {
  ...
  // The checkCalendarAvailablity() function checks the availability of the time slot.
  return checkCalendarAvailablity(dateTimeStart, dateTimeEnd).then(() => {
    // The time slot is available.
    // The function returns a response that asks for the confirmation of the date and time.
    agent.add(`Okay, ${appointmentDateString} at ${appointmentTimeString}. Did I get that right?`);
  }).catch(() => {
    // The time slot is not available.
    // The function suggests a new time slot.
    agent.add(`Sorry, we're booked on ${appointmentDateString} at ${appointmentTimeString}. But we have a spot the same time the next day. Do you want to book it?`);
    // Create a new time slot, which is the next day the same time.
    dateTimeStart.setDate(dateTimeStart.getDate() + 1);
    // Include the newly suggested time slot to the parameter 'suggested_time' of the context 'MakeAppointment-suggestion'
    agent.context.set({'name': 'makeappointment-suggestion', 'lifespan': 3, 'parameters': {'suggested_time': dateTimeStart}});
    // Delete the context 'MakeAppointment-followup' to return the flow of conversation to the beginning.
    agent.context.delete('makeappointment-followup');
  });

The checkAppointment() function calls the checkCalendarAvailablity() function to check if the specified time slot is available on the calendar. Based on the result from this call, the checkAppointment() function decides which path to take. If the result is positive, the function stays on the happy path and moves on to the confirmation step. However, if the result reveals that the time slot is not available, the function takes a detour by performing the following actions:

  • Increments the time slot's date value by 1 to suggest a new time slot (the dummy approach).
  • Activates the context MakeAppointment-suggestion.
    • Creates a parameter named suggested_time.
    • Assigns the new time slot to the parameter suggested_time.
  • Deletes the context MakeAppointment-followup.
  • Asks if the user wants to accept the new time slot:
    • Sorry, we're booked on Wednesday, August 1 at 3 PM. But we have a spot the same time the next day. Do you want to book it?

When the user accepts the suggested time slot, the follow-up intent Make Appointment - Suggestion - yes runs the suggestAppointment() function:

function suggestAppointment (agent) {
  // Get the context 'MakeAppointment-suggestion'
  const suggestContext = agent.context.get('makeappointment-suggestion');
  // Construct an instance of Date object using the 'suggested_time' parameter.
  const dateTimeStart = new Date(suggestContext.parameters.suggested_time);
  // Convert the Date object into human-readable strings.
  const appointmentTimeString = getLocaleTimeString(dateTimeStart);
  const appointmentDateString = getLocaleDateString(dateTimeStart);
  // Asks for the confirmation of the date and time values.
  agent.add(`Great. ${appointmentDateString} at ${appointmentTimeString}. Did I get that right?`);
  // Include the newly suggested time slot to the parameter 'suggested_time' of the context 'MakeAppointment-followup'.
  agent.context.set({'name': 'makeappointment-followup', 'lifespan': 3, 'parameters': {'suggested_time': dateTimeStart}});
  // Delete the context 'MakeAppointment-suggestion'.
  agent.context.delete('makeappointment-suggestion');
  return;
}

To return the flow of conversation to the confirmation step on the happy path, the suggestAppointment() function performs the following tasks:

  • Deletes the context MakeAppointment-suggestion to deactivate the MakeAppointment - Suggestion - yes and MakeAppointment - Suggestion - no intents.
  • Turns back on the context MakeAppointment-followup to activate the MakeAppointment - yes and MakeAppointment - no intents, which listen for the user's confirmation of the appointment.

Notice the suggestAppointment() function returns a response that asks the user to confirm the time and date of the appointment:

Great. Thursday, August 2 at 3 PM. Did I get that right?

At this point, the flow of conversation returns to the happy path as if the detour was never taken, except now the parameter suggested_time contains the valid time slot for the appointment—we no longer need to evaluate the parameters date and time.

What's next

In this tutorial, we've delved into contexts and learned how to manage the state and flow of conversation. We've also examined ways to extend and enhance the agent's capabilities using contexts.

The agent is now better prepared to handle deviations in conversation than at the beginning of this tutorial. However, as you continue to test your agent, you'll encounter more situations that challenge the agent's ability to provide a frictionless user experience. Be sure to smooth out those rough edges by applying the techniques and best practices demonstrated in this tutorial and the Build an agent from scratch using best practices tutorial.

To learn more about Dialogflow's contexts and Actions on Google, check out the following pages: