Contents

Building a Simple Quiz Chatbot With Twilio Autopilot and Serverless

The first chatbot can be dated back to 1966 when ELIZA was created at the MIT Artificial Intelligence Laboratory. Since then chatbots have developed significantly. Users interact via text or text-to-speech, and implementations range from detecting input using regular expressions and returning scripted responses to fully conversational AI featuring natural language processing, think Google Assistant or Amazon Alexa.

Twilio Autopilot is Twilio’s offering to build AI-powered chatbots which you can connect to a wide variety of channels, including Voice, SMS, Chat, WhatsApp and Facebook Messenger. In this article we’ll combine Twilio Autopilot with the Serverless Framework and AWS Lambdas to extend the functionalities of a chatbot. In a later article we’ll cover the conversational features Twilio Autopilot has to offer.

Our finished product will be a geography quiz where users can play Guess the capital (given a country the user must select the correct capital from a range of choices) or Guess the country (given the outlines of a country the user must select the correct country from a range of choices). The questions will be delivered and checked by Python Lambdas. The following short screen recording shows an interaction with the chatbot via WhatsApp.

 

Twilio Autopilot Definition

To develop and deploy a Twilio Autopilot from the command line first install the Twilio CLI and the Twilio Autopilot CLI Plugin. The former can be achieved using npm with npm install twilio-cli -g and the latter by running twilio plugins:install @dabblelab/plugin-autopilot.

To init the chatbot run twilio autopilot:init -p twilio-profile -n geography-quiz-bot, this will create a directory structure similar to this:

geography-quiz-bot/
├── ...
└── model
    └── schema.json

The schema.json holds the definition of our chatbot, copy & paste the following:

{
    "friendlyName": "Geography Quiz Bot",
    "logQueries": true,
    "uniqueName": "geography-quiz-bot",
    "defaults": {
        "defaults": {
            "assistant_initiation": "task://menu",
            "fallback": "task://menu",
            "collect": {
                "validate_on_failure": "task://collect_fallback"
            }
        }
    },
    "styleSheet": {
        "style_sheet": {
            "collect": {
                "validate": {
                    "on_failure": {
                        "repeat_question": false,
                        "messages": [
                            {
                                "say": ""
                            }
                        ]
                    },
                    "on_success": {
                        "say": ""
                    },
                    "max_attempts": 3
                }
            },
            "voice": {
                "say_voice": "Polly.Matthew"
            }
        }
    },
    "fieldTypes": [
    ],
    "tasks": [
        {
            "uniqueName": "collect_fallback",
            "actions": {
                "actions": [
                    {
                        "say": "I've got a bit lost now, apologies for that. Let's start again, how can I help you today?"
                    },
                    {
                        "listen": true
                    }
                ]
            },
            "fields": [],
            "samples": []
        },
        {
            "uniqueName": "capital",
            "actions": {
                "actions": [
                    {
                        "redirect": {
                            "uri": "https://xyz.execute-api.eu-west-2.amazonaws.com/dev/gqb/capital",
                            "method": "POST"
                        }
                    }
                ]
            },
            "fields": [],
            "samples": [
                {
                    "language": "en-US",
                    "taggedText": "capital"
                },
                {
                    "language": "en-US",
                    "taggedText": "Capital"
                },
                {
                    "language": "en-US",
                    "taggedText": "CAPITAL"
                }
            ]
        },
        {
            "uniqueName": "country",
            "actions": {
                "actions": [
                    {
                        "redirect": {
                            "uri": "https://xyz.execute-api.eu-west-2.amazonaws.com/dev/gqb/country",
                            "method": "POST"
                        }
                    }
                ]
            },
            "fields": [],
            "samples": [
                {
                    "language": "en-US",
                    "taggedText": "country"
                },
                {
                    "language": "en-US",
                    "taggedText": "Country"
                },
                {
                    "language": "en-US",
                    "taggedText": "COUNTRY"
                }
            ]
        },
        {
            "uniqueName": "menu",
            "actions": {
                "actions": [
                    {
                        "say": "Welcome to the Geography Quiz Bot!\nWhat mode do you want to play?\nType 'capital' for guess the capital and 'country' for guess the country."
                    },
                    {
                        "listen": true
                    }
                ]
            },
            "fields": [],
            "samples": [
                {
                    "language": "en-US",
                    "taggedText": "menu"
                },
                {
                    "language": "en-US",
                    "taggedText": "start"
                },
                {
                    "language": "en-US",
                    "taggedText": "hi"
                },
                {
                    "language": "en-US",
                    "taggedText": "hello"
                }
            ]
        }
    ],
    "modelBuild": {
        "uniqueName": "v0.1"
    }
}

It’s quite long so let’s break it down by looking at the first level of the dictionary:

{
    "friendlyName": "Geography Quiz Bot",
    "logQueries": true,
    "uniqueName": "geography-quiz-bot",
    "defaults": { ... },
    "styleSheet": { ... },
    "fieldTypes": [ ... ],
    "tasks": [ ... ],
    "modelBuild": { ... }
}

This sets:

  • The name of the chatbot using friendlyName and uniqueName,
  • a configuration setting to log all queries made to the chatbot by setting logQueries to true,
  • defaults for the chatbot to fallback to,
  • a styleSheet which sets the voice to be used and other items,
  • a list of fieldTypes definitions if custom field types are used,
  • a list of tasks,
  • and a string indicating the modelBuild of the chatbot.

Looking at the tasks you can see we defined four tasks:

  • collect_fallback - This task is used whenever the response recognition encounters problems or hits the maximum number of attempts. It outputs a friendly message and then continues to listen for new task trigger words using "listen": true.
  • capital & country - This is triggered when the user says capital or country. It fetches the actions definitions via the endpoints noted in "redirect": {}, i.e. via one of the Python Lambdas we’re going to implement.
  • menu - This is triggered when the user says hi, menu, or similar. It outputs a friendly message and then continues to listen for new task trigger words using "listen": true.

With all this in place let’s deploy the chatbot by running twilio autopilot:deploy -l debug --target model.

Head over to the Autopilot Overview in the Twilio Console and you should see the chatbot appearing. /2021/07/building-a-simple-quiz-chatbot-with-twilio-autopilot-and-serverless/Twilio_Console-Autopilot_List.png

 

Serverless Setup

To get started with Serverless first install the CLI using npm with npm install serverless -g.

To init the Serverless project run sls create --template aws-python --path geography-quiz-bot.

Our serverless.yml looks like this:

service: geography-quiz-bot

frameworkVersion: '2'

provider:
  name: aws
  runtime: python3.8
  region: eu-west-2
  profile: your-aws-profile
  tracing:
    apiGateway: true
    lambda: true
  lambdaHashingVersion: 20201221

resources:
  Resources:
    images:
      Type: AWS::S3::Bucket
      Properties:
        AccessControl: PublicRead
        BucketName: geography-quiz-bot-images
    imagesPublicReadPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: !Ref images
        PolicyDocument:
          Statement: 
            - Effect: Allow
              Action:
                - 's3:GetObject'
              Resource: !Join
                - ''
                - - 'arn:aws:s3:::'
                  - !Ref images
                  - /*
              Principal: '*'

functions:
  capital:
    handler: handler.capital
    events:
    - http:
        path: gqb/capital
        method: post
  capital-check:
    handler: handler.capital_check
    events:
    - http:
        path: gqb/capital-check
        method: post
  country:
    handler: handler.country
    events:
    - http:
        path: gqb/country
        method: post
  country-check:
    handler: handler.country_check
    events:
    - http:
        path: gqb/country-check
        method: post

This defines a Serverless project with Python 3.8 as the main runtime. If you’re copying & pasting this make sure to adjust the AWS region & profile to match your details.

In resources we create a AWS S3 bucket which will hold the images of the country outlines for the guess the country mode, we’ll see later how we seed the data. We’re attaching a AWS S3 bucket policy so that the images are publicly accessible. We’ll reference them via the S3 bucket URL in our chatbot messages.

We define four functions: country and capital are the entry points matching the modes from the Twilio Autopilot definition, country-check and capital-check are used to check the users answer and forward to the main menu of the chatbot.

Next let’s look at the functions in detail.

 

Lambdas for the Capital Quiz

For the Lambdas we define two Twilio Autopilot Action templates which we can copy and fill, they look like this:

BODY_QUESTION = {
    'actions': [
        {
            'remember': {}
        },
        {
            'collect': {
                'name': 'questions',
                'questions': [
                    {
                        'question': '',
                        'name': '',
                        'validate': {
                            'on_failure': {
                                'messages': [
                                    {
                                        'say': 'Please answer by typing A, B, C or D.'
                                    }
                                ]
                            },
                            'allowed_values': {
                                'list': ['a', 'b', 'c', 'd', 'A', 'B', 'C', 'D']
                            },
                            'max_attempts': {
                                'redirect': 'task://collect_fallback',
                                'num_attempts': 3
                            }
                        }
                    }
                ],
                'on_complete': {
                    'redirect': ''
                }
            }
        }
    ]
}

BODY_ANSWER = {
    'actions': [
        {
            'say': ''
        },
        {
            'listen': True
        }
    ]
}

This should be familiar by now as this is exactly an Action definition like we supplied for schema.json, turns out if we do a redirect all we need to reply with is a set of Action definitions as a JSON and Twilio Autopilot will take those and perform the actions. For the question we have a remember and collect part and for the answer a say and listen part. With remember we use the Twilio Autopilot memory to store information across multiple steps. collect aids us in collecting the answer from the user, in this case the user can only type A, B, C or D and our Action definition ensures this.

 

def capital(event, context):
    body = copy.deepcopy(BODY_QUESTION)
    choices = list(data.COUNTRY_TO_CAPITAL.keys())

    answer_keys = []
    random_answer_key = random.choice(choices)
    while len(answer_keys) < 4 and random_answer_key not in answer_keys:
        answer_keys.append(random_answer_key)
        random_answer_key = random.choice(choices)

    answers = {c: {
        'country': answer_keys[i],
        'capital': data.COUNTRY_TO_CAPITAL[answer_keys[i]],
    } for c, i in zip(['A', 'B', 'C', 'D'], range(4))}

    correct_choice = random.choice([(0, 'A'), (1, 'B'), (2, 'C'), (3, 'D')])

    body['actions'][0]['remember'] = {
        'correct_answer_idx': correct_choice[0],
        'correct_answer_key': correct_choice[1],
        'correct_answer_country': answers[correct_choice[1]]['country'],
        'correct_answer_capital': answers[correct_choice[1]]['capital'],
    }
    question = f"What's the capital of {answers[correct_choice[1]]['country']}?\n"
    question += "\n".join([f"{option}) {answer['capital']}" for option, answer in answers.items()])
    body['actions'][1]['collect']['questions'][0]['question'] = question
    body['actions'][1]['collect']['questions'][0]['name'] = 'capital'
    body['actions'][1]['collect']['on_complete']['redirect'] = \
        'https://xyz.execute-api.eu-west-2.amazonaws.com/dev/gqb/capital-check'

    response = {
        'statusCode': 200,
        'body': json.dumps(body)
    }

    return response

This is the Lambda which creates a random guess a capital question. For that it picks a random choice from COUNTRY_TO_CAPITAL, which is a list formatted like this COUNTRY_TO_CAPITAL = {Afghanistan": "Kabul", "Albania": "Tirana", ...}, selects further three random answers for the remaining options and fills in the template, i.e. in remember we store the correct choice in order to be able to check the answer when the user responds. The collect part is filled with the dynamically created question, note that on completion we’re redirecting to the capital-check Lambda to check the answer.

 

def capital_check(event, context):
    body = copy.deepcopy(BODY_ANSWER)

    event_body = parse_qs(event['body'])
    memory = json.loads(event_body['Memory'][0])

    if memory['correct_answer_key'].lower() == \
       memory['twilio']['collected_data']['questions']['answers']['capital']['answer'].lower():
        say = "Yes you're right, "
    else:
        say = "No that's wrong, "
    say += f"{memory['correct_answer_capital']} is the capital of {memory['correct_answer_country']}."

    body['actions'][0]['say'] = say

    response = {
        'statusCode': 200,
        'body': json.dumps(body)
    }

    return response

All we need to do here is extract the users answer (stored in the memory under twilio/collected_data/questions/answer/question_name/answer) and compare it to the correct answer which we stored in the custom part of the memory (correct_answer_key in our case).

 

Lambdas for the Country Quiz

 

Seeding Data

The country outlines for the guess the country mode are created with the following Python script:

#!/usr/bin/env python3
# encoding: utf-8

import boto3
import cartopy
import cartopy.io.shapereader as shpreader
import matplotlib.pyplot as plt


boto3.setup_default_session(profile_name='your-aws-profile')
s3_client = boto3.client('s3')
bucket = 'geography-quiz-bot-images'

shpfilename = shpreader.natural_earth(
    resolution='10m',
    category='cultural',
    name='admin_0_countries'
)
reader = shpreader.Reader(shpfilename)
countries = reader.records()

allowed_types = ['Sovereign country', 'Country', 'Dependency']
filtered_countries = [country for country in countries if country.attributes['TYPE'] in allowed_types]
print('Number of countries:', len(filtered_countries))

for country in filtered_countries:
    name_long = country.attributes['NAME_LONG']
    iso = country.attributes['ADM0_A3']
    filename = f"{name_long.replace(' ', '_')}-{iso}.png"
    ax = plt.axes(projection=cartopy.crs.PlateCarree())
    ax.add_feature(cartopy.feature.LAND)
    ax.add_feature(cartopy.feature.OCEAN)
    ax.add_feature(cartopy.feature.BORDERS, linestyle='-', linewidth=.25)
    ax.outline_patch.set_edgecolor('grey')
    bounds = [coord for coord in country.geometry.bounds]
    ax.set_global()
    ax.set_extent([bounds[0], bounds[2], bounds[1], bounds[3]], cartopy.crs.PlateCarree())
    ax.add_geometries(
        country.geometry,
        cartopy.crs.PlateCarree(),
        facecolor=('#9CCC9C'),
        label=iso,
        edgecolor='grey',
        linewidth=.25
    )
    plt.savefig(filename, bbox_inches='tight', pad_inches=0.0, dpi=300)
    plt.clf()
    s3_client.upload_file(filename, bucket, filename, ExtraArgs={'ContentType': 'image/png'})

Note that the code is simplified and error handling is omitted. We use cartopy, a package designed for geospatial data processing, to retrieve the shapes of sovereign countries and matlplotlib to plot a png file.

shpfilename holds the shape file we’re going to use with the cartopy shapereader. The reader object is formed and the read with:

reader = shpreader.Reader(shpfilename)
countries = reader.records()

We need to filter the list as it contains also shapes of non-sovereign countries.

Next we iterate over all countries, plot and upload them to a AWS S3 bucket:

ax = plt.axes(projection=cartopy.crs.PlateCarree())
[...]
ax.add_geometries(...)
plt.savefig(filename, bbox_inches='tight', pad_inches=0.0, dpi=300)
[...]
s3_client.upload_file(filename, bucket, filename, ExtraArgs={'ContentType': 'image/png'})

This is all pretty straight-forward except getting the bounding box of the shape and setting the extent of the plot so that the map is centered and zoomed in on it.

In the end we have a png file for each sovereign country in our AWS S3 bucket, see the serverless.yml on how we define the AWS S3 bucket.

 

Connecting the Chatbot to WhatsApp

Lastly we need to connect our brand new chatbot with WhatsApp, there’s a couple of steps involved:

  1. First get a phone number with Twilio
  2. Next you need to get the Twilio phone number approved to be used with WhatsApp, this can take some time but you can test with the Twilio Sandbox for WhatsApp in the meantime
  3. The easiest way to connect the chatbot with the phone number is by creating a Messaging Service, to do this:
    • Go to the Programmable Messaging section in the Twilio Console
    • Go to Messaging Services in the left hand menu /2021/07/building-a-simple-quiz-chatbot-with-twilio-autopilot-and-serverless/Twilio_Console-Messaging_Services_Overview.png
    • Click on Create Messaging Service and give your new messaging service a name before you confirm /2021/07/building-a-simple-quiz-chatbot-with-twilio-autopilot-and-serverless/Twilio_Console-Create_Messaging_Service.png
  4. Now we need to add a phone number to the messaging service, i.e. we need to fill the sender pool
    • Go to the detail page of the messaging service
    • Go to Sender Pool in the left hand menu /2021/07/building-a-simple-quiz-chatbot-with-twilio-autopilot-and-serverless/Twilio_Console-Messaging_Service_Sender_Pool.png
    • Click on Add Senders and add a WhatsApp capable phone number (select Whatsapp for that and make sure the phone number starts with whatsapp:) /2021/07/building-a-simple-quiz-chatbot-with-twilio-autopilot-and-serverless/Twilio_Console-Messaging_Service_Sender_Pool_Add_WhatsApp.png
  5. Finally set up the integration for the messaging service, i.e. we need to connect to the Autopilot webhook
    • Go to the detail page of the messaging service
    • Go to Integration in the left hand menu and select Send a webhook and fill in your Twilio Autopilot webhook URL in Request URL /2021/07/building-a-simple-quiz-chatbot-with-twilio-autopilot-and-serverless/Twilio_Console-Messaging_Service_Integration.png

 

Conclusion