Showing posts with label Alexa Skill. Show all posts
Showing posts with label Alexa Skill. Show all posts

Tuesday, February 26, 2019

CIA World Factbook on AWS, Part 3: Alexa Voice Interface using Lambda and DynamoDB

In this 3-part series, I'm showing how you can take CIA World Factbook data and use it for your own purposes on Amazon Web Services. Previously in Part 1 we created the back-end to collect data and store it in DynamoDB and S3 storage, using a Lambda Function to insert document records. In Part 2 we created a Lambda API for data access and a web site for browsing, searching, and viewing charts. Today in Part 3 we're creating an Alexa Skill so that the world country data can be accessed by voice.


What We're Building

Unless you've been living under a rock, you know that Amazon's cloud-based voice service is named Alexa and can be accessed from... well, anywhere: devices like the Amazon Echo and Dot; on your TV via Amazon FireStick; in a variety of cars; and many other places. Even if you don't own an Alexa-enabled device, you can get to right now on the web at https://alexa.amazon.com; or from your phone using the Alexa app.

You can access the country data skill with "Alexa, Open World Country Data".

We are going to create an Alexa Skill (voice app) named World Country Data, backed by a Lambda Function that responds to spoken inquiries. The Lambda Function will query the DynamoDB database to retrieve country data.

World County Data Dialog

The Alexa skill will be able to respond to inquiries like these:

  • What is the population of country?
  • Where is country?
  • How large is country?
  • What language is spoken in country?
  • What are the major cities in country?
  • What countries border country?
  • What are the natural resources of country?
  • What are the agricultural products of country?
  • What are the industries of country?
  • Give me an overview of country
  • Brief me on the economy of country
  • How many mobile phones are in country?
In addition, a user can also inquire about how countries compare and rank:
  • Wha country is the largest?
  • Which countries have the highest exports?
  • What countries have the most Internet users?
  • What country has the lowest population?
We also want the ability to play an audio clip:
  • Play the national anthem of country
Here's what happens architecturally: spoken inquires from users to Alexa undergo speech recognition, machine learning, and natural language processing. Alexa recognizes an intent and passes it to our Lambda Function. Factbook skill looks up the required data from the DynamoDB which contains JSON records for each country. The function returns a speech response, which in some cases may reference an audio clip. For Alexa devices with a display, the response also includes a card, which displays has a map or flag image (depending on device size). Audio clips and images are stored in S3.

Achitecture of World Country Data Skill

Our project will consist of a Alexa Skill (configuration) and a Lambda Function to go with it (code). We'll start with the Alexa skill. Our starting point for the project was the Color Picker sample in Node.js, which provides basic skeleton code for a skill.

Alexa Skill

To avoid potential confusion, I should mention that as I started work on this leg of the project, it came to my attention that there is already an existing World Factbook skill (not from me). Don't confuse that with my skill which is named World Country Data. The two skills are pretty different in scope, however.

Alexa Skills are created in the Amazon developer portal, developer.amazon.com, not the AWS console. You'll need to register as a developer. Our skill is named World Country Data.

The first area to set up in the skill project are the intents. An intent is something a user is trying to communicate, such as What's the population of {country} and contains a list of utterances. Intents are easy and not easy at the same time: it's simple enough to enter some utterances for the intent and try it out on Alexa. What's not so easy is thinking through all the possible ways someone might phrase an inquiry.

Scalar Value Inquiries

One category of intents we'll have is inquires about scalar values, such as What's the population of {country}?, that return a single value (in this case, a number). Here's our definition for the population intent:


The embedded value {country} is called a slot, which is a type of placeholder. We want to be able to ask these questions about any country. Further down in the intent, we see that the slot country is of type country, meaning a custom list of country names we will create.


There have been a number of places in this project where it's been necessary to list 260 individual countries. Defining the possible slot values for country is one of them. My fingers are getting a good work out! Technically speaking, the intents would work without listing every possible country here; however, there's a huge improvement in Alexa's understanding and selecting the right intent when the expected slot values are defined.

Defining country Slot Values

The response to this intent will be provided by our Lanbda Function, which we'll get to later in this post. We create similar intents for area, climate, terrain, literacy rate, and the number of phones. This hardly scratches the surface of the available data; we'll come back and do more someday.

Text Narratives

Some fields in the country JSON contain paragraph text. For example, there's introduction.background, a background preamble on the country; and economy.overview, a brief on a country's economy. Technically, retrieving these items is no different than the scalar values described in the prior section; the experience to the user is quite different, however, as Alexa will read on and on. We handle these kind of inquires in the same way, with intents.

Alexa Dialog: Country Overview

Lists

Some of the World Factbook data is in the form of lists (JSON object arrays), such as a country's agricultural products or major urban areas.

Alexa Dialog: Major Urban Areas

Our intents for lists are not much different than our intents for scalar values: the only slot needed is the country name. However, our Lambda Function will need different code to retrieve list data.

Intent major_cities: Utterances

Top Countries

In addition to asking facts about a particular country, a user might want to know which countries are ranked highest or lowest in various categories like area, population, exports, or inflation. In Part 2 we created Lamba functions and database queries to get this information and render column charts. Today we can use the same queries (such as Top 10 exports) and give a response like this: The countries with the largest area are Russia, Antarctica, and Canada.

Alexa Dialog: Leading Countries

As before, our intents need to consider a variety of utterances that convey the same meaning:

Intent area_highest: Utterances

We create intents for largest/smallest area, highest/lowest population, highest/lowest inflation rate, and most Internet users. We'll look at the backing Lambda functions later in this post. Once again, there's so much additional data we could mine. 

Playing National Anthems

The CIA World Factbook data includes audio clips for most countries' national anthems. Being able to play this audio in the skill is one of my favorite features.

To support this in our skill took some work, because Alexa imposes some limitations on audio clips:
  • Audio clips must be hosted at an HTTPS endpoint.
  • The MP3 must be an MPEG version 2 file.
  • The audio file cannot be longer than 240 seconds.
  • The bit rate must be 48 kbps.
  • The sample rate must be 22050Hz, 24000Hz, or 16000Hz.
These are not great specs for music audio, but we have no choice if we are going to be able to play our clips in our Alexa skill. 

After obtaining the audio clips for each country, it was necessary to transform them to meet the above specifications. Following the AWS guidance on audio conversion, the following steps were performed for each national anthem MP3 file, using a free audio tool named Audacity.
  1. Open the nation anthem MP3.
  2. Change the Audactity projects' sampling rate to 16000.
  3. Export the audio to MP3, setting a bit rate of 48kbps.
Converting Audio files to 48 kbps with Audacity

After converting all the country national anthem files, they were uploaded to S3 which is where the Alexa Skill will play them from. We'll see how when we look at the backing Lambda Function.

Here's a sample national anthem clip for Austria.

Lambda Function

Our Alexa Skill depends on a Lambda Function writtein in Node.js. Starting with the Color Picker sample gives us a basic voice app, which we will now turn into Country World Data. Although this is just one Lambda function, it calls into several sub-functions we'll need to review.

onIntent

The onIntent function responds to an intent by calling an appropriate handler function based on the intent name:

  • Scalar value requests and text briefing requests are handled by the lookup function.
  • List value requests are handled by the lookupList function.
  • Top countries in a category requests are handled by the lookupTop function.
/**
 * Called when the user specifies an intent for this skill.
 */
function onIntent(intentRequest, session, callback) {
    console.log(`onIntent requestId=${intentRequest.requestId}, sessionId=${session.sessionId}`);

    const intent = intentRequest.intent;
    const intentName = intentRequest.intent.name;

    if (intentName === 'about')                 { lookup('introduction.background', '{value}{end}', intent, session, callback); } 
    else if (intentName === 'area')             { lookup('geography.area.total.value', '{country} is {value} square kilometers in size.{end}', intent, session, callback); }  
    else if (intentName === 'climate')          { lookup('geography.climate', '{value}{end}', intent, session, callback); } 
    else if (intentName === 'count_mobile_phones') { lookup('communications.telephones.mobile_cellular.total_subscriptions', 'There are {value} mobile phones in {country}.{end}', intent, session, callback); } 
    else if (intentName === 'count_land_phones') { lookup('communications.telephones.fixed_lines.total_subscriptions', 'There are {value} land lines in {country}.{end}', intent, session, callback); } 
    else if (intentName === 'economy')          { lookup('economy.overview', '{value}{end}', intent, session, callback); } 
    else if (intentName === 'terrain')          { lookup('geography.terrain', '{value}{end}', intent, session, callback); } 
    else if (intentName === 'where')            { lookup('geography.location', '{value}{end}', intent, session, callback); }  
    else if (intentName === 'population')       { lookup('people.population.total', 'The population of {country} is {value}{end}.', intent, session, callback); } 
    else if (intentName === 'urban_population') { lookup('people.urbanization.urban_population.value', 'The urban population of {country} is {value} percent.{end}', intent, session, callback); } 
    else if (intentName === 'literacy_rate')    { lookup('people.literacy.total_population.value', 'The literacy rate of {country} is {value} percent.{end}', intent, session, callback); } 

    else if (intentName === 'play_national_anthem') { lookup(null, '{audio}{end}', intent, session, callback); } 
    
    else if (intentName === 'agricultual_products') { lookupList('economy.agriculture_products.products', null, '{country} has these agricultural products: {list}.{end}', intent, session, callback); } 
    else if (intentName === 'bordered_by')      { lookupList('geography.land_boundaries.border_countries', 'country', '{country} is bordered by: {list}.{end}', intent, session, callback); } 
    else if (intentName === 'industries')       { lookupList('economy.industries.industries', null, '{country} has these industries: {list}.{end}', intent, session, callback); } 
    else if (intentName === 'languages')        { lookupList('people.languages.language', 'name', 'In {country}, these languages are spoken: {list}.{end}', intent, session, callback); } 
    else if (intentName === 'major_cities')     { lookupList('people.major_urban_areas.places', 'place', 'In {country}, the major urban areas are: {list}.{end}', intent, session, callback); } 
    else if (intentName === 'resources')        { lookupList('geography.natural_resources.resources', null, '{country} has these natural resources: {list}.{end}', intent, session, callback); } 

    else if (intentName === 'area_highest')     { lookupTop('report-area-highest', 'The countries with the largest area are {country1}, {country2}, and {country3}.{end}', intent, session, callback); } 
    else if (intentName === 'area_lowest')      { lookupTop('report-area-lowest', 'The countries with the smallest area are {country1}, {country2}, and {country3}.{end}', intent, session, callback); } 
    else if (intentName === 'exports_highest')  { lookupTop('report-exports-highest', 'The countries with the highest exports are {country1}, {country2}, and {country3}.{end}', intent, session, callback); } 
    else if (intentName === 'exports_lowest')   { lookupTop('report-exports-lowest', 'The countries with the lowest exports are {country1}, {country2}, and {country3}.{end}', intent, session, callback); } 
    else if (intentName === 'imports_highest')  { lookupTop('report-imports-highest', 'The countries with the highest imports are {country1}, {country2}, and {country3}.{end}', intent, session, callback); } 
    else if (intentName === 'imports_lowest')   { lookupTop('report-imports-lowest', 'The countries with the lowest imports are {country1}, {country2}, and {country3}.{end}', intent, session, callback); } 
    else if (intentName === 'internet_users_most') { lookupTop('report-internet-users-highest', 'The countries with the most Internet users are {country1}, {country2}, and {country3}.{end}', intent, session, callback); } 

    else if (intentName === 'population_highest') { lookupTop('report-population-highest', 'The countries with the highest population are {country1}, {country2}, and {country3}.{end}', intent, session, callback); } 
    else if (intentName === 'population_lowest') { lookupTop('report-population-lowest', 'The countries with the lowest population are {country1}, {country2}, and {country3}.{end}', intent, session, callback); } 

    else if (intentName === 'AMAZON.HelpIntent') { help(intent, session, callback); } 
    else if (intentName === 'AMAZON.StopIntent' || intentName === 'AMAZON.CancelIntent') { handleSessionEndRequest(callback); }
    else if (intentName === 'AMAZON.FallbackIntent') { huh(intent, session, callback); }

    else { throw new Error('Invalid intent: ' + intentName); }
}
onIntent function

Now let's take a look at our three primary handlers: lookup, lookupList, and lookupTop.

lookup


lookup is called to get a scalar value, such as the people.population.total value in the JSON below.

JSON snippet - Total Population for Greece

We can call the lookup function with the following path to retrieve the population total:

lookup('people.population.total', 'The population of {country} is {value}.{end}', intent, session, callback);

The lookup function is passed a JSON document path; a response template; and intent, session, and callback variables. The intent can be inspected, the session can be used to store or retrieve session state, and the callback is invoked to return a response. Here's the code to lookup:
// --------------- lookup - look up a value - e.g. What is the {property} of {country}? | What is the population of France?
// path: a dotted path to the data, as in people.population.total

function lookup(path, response, intent, session, callback) {

    const repromptText = 'Please ask me a country fact, such as "What is the population of France"?';
    const sessionAttributes = {};
    let shouldEndSession = false;
    let speechOutput = '';
    FlagImageUrl = null;
    MapImageUrl = null;
    let cardTitle = 'World Country Data';
    
    let country = normalizeCountryName(intent.slots.country.value);
    
    if (!inCountryList(country)) { // also sets FlagImageUrl & MapImageUrl if country name recognized
        console.log('lookup: country not recognized: ' + country);
        cardTitle = 'World Country Data - Country Not Recognized';
        speechOutput = 'Sorry, I don\'t recognize that country name. ' + Tag;
        callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    }
    else {
        cardTitle = 'World Country Data - ' + country;
        const AWS = require('aws-sdk');
        AWS.config.update({region: 'us-east-1'});
        const docClient = new AWS.DynamoDB.DocumentClient({region: 'us-east-1'}); 
    
        var params = {
          TableName: 'factbook',
          ExpressionAttributeNames: {
             '#name': 'name',
             '#source': 'source'
          },
          ExpressionAttributeValues: {
            ':name': country,
            ':source': 'Factbook'
          },
          KeyConditionExpression: '#name = :name and #source = :source'
        };
        
        docClient.query(params, function(err, data) {
    
            if (err) { 
                console.log('lookup: Query Error - ' + err.toString());
                speechOutput = 'Sorry, an error occurred looking up the data.';
                shouldEndSession = true;
            } else { 
                if (!data || data.Items.length===0) {
                    speechOutput = 'Sorry, I found no data. ' + Tag;
                }
                else {
                    try {
                        response = replace(response, '{country}', country);
                        response = replace(response, '{value}', eval('data.Items[0].' + path));
                        response = replace(response, '{end}', Tag);
                        response = replace(response, '{audio}', '<audio src="https://s3.amazonaws.com/factbookaudio/' + replace(country, ' ', '+') + '.mp3"/>');
                        speechOutput = response;
                    }
                    catch(e) {
                        console.log('lookup: Exception - ' + e.toString());
                        speechOutput = "Sorry, I had a problem looking that up.";
                        shouldEndSession = true;
                    }
                }
                    
            // Setting repromptText to null signifies that we do not want to reprompt the user.
            // If the user does not respond or says something that is not understood, the session
            // will end.
            callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            }
        });
    }
}
lookup function

The above code calls normalizeCountryName to normalize the country name (line 14). Many countries have alternate or historical names a user might refer to, and we need to arrive at a standard name that will match the JSON record in DynamoDB and the filenames in S3. For example, a user can request data on America, the U.S., or the U.S.A. and the country name will be normalized to United States.

Next (lines 16-21) we check whether the country name is in our list of country names. If it isn't, we won't be able to retrieve the requested data and we respond Sorry, I don't recognize that country name. The response speech is routed back by calling buildSpeechletResponse, which we'll review later.

If we do have a recognized country name, it's time to retrieve the requested data. A DynamoDB client is instantiated (lines 24-26), query parameters are set up to retrieve the JSON country record (lines 28-39), and the JSON document is retrieved (lines 41-71). The code uses JavaScript's eval function to get to the data, which isn't a great practice; we'll be looking to rewrite that code in the near future and not use eval.

We've been passing spoken responses and referring to them as templates; this is something I created, not an Alexa feature. To return the spoken response, the template response text passed in has values replaced:

  • {country} is replaced with the normalized country name;
  • {value} is replaced with the desired value by evaluating the property path that was passed in to the function.
  • {audio} is replaced with markup for the national anthem sound clip. 
  • {end} is replaced with a 1-second pause and "What else would you like to know?" prompt.
The response is passed back to Alexa by calling buildSpeechletResponse (line 69).

The response to What is the population of Greece? is The population of Greece is 10 million 761 thousand 523.

lookupList


lookupList is very similar to lookup, but has different code for retrieving the value. Instead of a single property, an array must be iterated through with an inner property extracted to speak. Here's an example of the JSON array for languages in the JSON record for Spain. In this case, the path to the array is people.languages.language but the property to be extracted from each array element is name.


We can call lookupList with the following parameters to extract a list of languages:

lookupList('people.languages.language', 'name', 'In {country}, these languages are spoken: {list}.{end}', intent, session, callback);

Here's the code to lookupList:
// --------------- lookupList - look up a list - e.g. What languages are spoken in {country}? | What ethnic groups live in France?

// path: a dotted path to array data, as in people.languages.language
// property: property of a list item to vocalize - ex: name

function lookupList(path, property, response, intent, session, callback) {

    const repromptText = 'Please ask me a country fact, such as "What is the population of France"?';
    const sessionAttributes = {};
    let shouldEndSession = false;
    let speechOutput = '';
    FlagImageUrl = null;
    MapImageUrl = null;
    let cardTitle = 'World Country Data';
    
    let country = normalizeCountryName(intent.slots.country.value);
    
    if (!inCountryList(country)) { // also sets FlagImageUrl & MapImageUrl if country name recognized
        console.log('lookupList: country not recognized: ' + country);
        cardTitle = 'World Country Data - Country Not Recognized';
        speechOutput = 'Sorry, I don\'t recognize that country name. ' + Tag;
        callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    }
    else {
        cardTitle = 'World Country Data - ' + country;
        const AWS = require('aws-sdk');
        AWS.config.update({region: 'us-east-1'});
        const docClient = new AWS.DynamoDB.DocumentClient({region: 'us-east-1'}); 
    
        var params = {
          TableName: 'factbook',
          ExpressionAttributeNames: {
             '#name': 'name',
             '#source': 'source'
          },
          ExpressionAttributeValues: {
            ':name': country,
            ':source': 'Factbook'
          },
          KeyConditionExpression: '#name = :name and #source = :source'
        };
        
        docClient.query(params, function(err, data) {
    
            if (err) { 
                console.log('lookupList: Query Error - ' + err.toString());
                speechOutput = 'Sorry, an error occurred looking up the data.';
                shouldEndSession = true;
            } else { 

                if (!data || data.Items.length===0) {
                    speechOutput = 'Sorry, I found no data. ' + Tag;
                }
                else {
                    try {
                        var listText = '';
                        var listData =  eval('data.Items[0].' + path);
                        if (listData != '') {
                            for (var i = 0; i < listData.length; i++) {
                                if (i > 0) {
                                    listText += ", ";
                                }
                                if (property===null) {
                                    listText += listData[i];
                                }
                                else {
                                    listText += listData[i][property];
                                }
                            }
                        }
                        
                        response = replace(response, '{country}', country);
                        response = replace(response, '{list}', listText);
                        response = replace(response, '{end}', Tag);
                        response = replace(response, '{audio}', '<audio src="https://s3.amazonaws.com/factbookaudio/' + replace(country, ' ', '+') + '.mp3"/>');
                        speechOutput = response;
                    }
                    catch(e) {
                        console.log('lookupList: Exception - ' + e.toString());
                        speechOutput = "Sorry, I had a problem looking that up. ";
                        shouldEndSession = true;
                    }
                }
                    
            // Setting repromptText to null signifies that we do not want to reprompt the user.
            // If the user does not respond or says something that is not understood, the session
            // will end.
            callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            }
        });
    }
}
lookupList function

lookupList is nearly identical to lookup. It also normalizes and validates the country name, and retrieves the JSON country record from DynamoDB. The big difference is lines 56-70 where the code iterates through the array at path and extracts variable property from each array element to form a list to speak. The response template can specify {list} as a placeholder for the list.

The spoken response to What languages are spoken in Spain? is In Spain, these languages are spoken: Castilian Spanish, Catalan, Galician, Basque, Aranese along with Catalan, speakers.

listTop


When a user asks a country ranking question like "which countries are largest?", the listTop function handles the request. listTop runs a query to get a Top 10 country list, such as the 10 countries with largest area. These are the same queries we used to get column charts in the web site in Part 2.

We can call lookupTop with the following parameters to hear the top countries in a category:

lookupTop('report-area-highest', 'The countries with the largest area are {country1}, {country2}, and {country3}.{end}', intent, session, callback);

Here's the code to listTop. The report name parameter is used to set the index, projection expression, and sort direction for the DynamoDB query. The response template may specify {country1}, {country2}, and {country3} for the names of the top 3 countries in the result.
// --------- lookupTop - look up top (leading) countries for a report - e.g. which country has the highest exports?: which countries are biggest?
// report: report name, such as 'report-exports-highest'
// response: speach to output, which may include embeddeed placeholders {country1}, {country2}, {country3}

function lookupTop(report, response, intent, session, callback) {

    const repromptText = 'Please ask me a country fact, such as "What is the population of France"?';
    const sessionAttributes = {};
    let shouldEndSession = false;
    let speechOutput = '';

    var index = null;
    var projectionExpression = null;
    var scanIndexForward = true;
    
    var FlagImageUrl = null;
    var MapImageUrl = null;
    const cardTitle = 'World Country Data';
    
    switch(report) {
        case 'report-area-highest':
            index = 'rank-area-index';
            projectionExpression = "#name, global_rank_area, global_value_area";
            scanIndexForward = true;
            break;
        case 'report-area-lowest':
            index = 'rank-area-index';
            projectionExpression = "#name, global_rank_area, global_value_area";
            scanIndexForward = false;
            break;
        case 'report-exports-highest':
            index = 'rank-exports-index';
            projectionExpression = "#name, global_rank_exports, global_value_exports";
            scanIndexForward = true;
            break;
        case 'report-exports-lowest':
            index = 'rank-exports-index';
            projectionExpression = "#name, global_rank_exports, global_value_exports";
            scanIndexForward = false;
            break;
        case 'report-imports-highest':
            index = 'rank-imports-index';
            projectionExpression = "#name, global_rank_imports, global_value_imports";
            scanIndexForward = true;
            break;
        case 'report-imports-lowest':
            index = 'rank-imports-index';
            projectionExpression = "#name, global_rank_imports, global_value_imports";
            scanIndexForward = false;
            break;
        case 'report-internet-users-highest':
            index = 'rank-internet-users-index';
            projectionExpression = "#name, global_rank_internet_users, global_value_internet_users";
            scanIndexForward = true;
            break;
        case 'report-population-highest':
            index = 'rank-population-index';
            projectionExpression = "#name, global_rank_population, global_value_population";
            scanIndexForward = true;
            break;
        case 'report-population-lowest':
            index = 'rank-population-index';
            projectionExpression = "#name, global_rank_population, global_value_population";
            scanIndexForward = false;
            break;
        default:
            report = null;
            break;
    }
    
    if (report===null) {
        speechOutput = "Sorry, I could not find that data. ";
        shouldEndSession = true;
        callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    }
    else {
        const AWS = require('aws-sdk');
        AWS.config.update({region: 'us-east-1'});
        const docClient = new AWS.DynamoDB.DocumentClient({region: 'us-east-1'}); 
    
        var params = {
          TableName: 'factbook',
          IndexName: index,
          ExpressionAttributeNames: {
             '#name': 'name',
             '#source': 'source'
          },
          ExpressionAttributeValues: {
            ':source': 'Factbook',
          },
          KeyConditionExpression: '#source = :source',
          ProjectionType : "ALL",
          ProjectionExpression: projectionExpression,
          Limit: 10,
          ScanIndexForward: scanIndexForward
        };
        
        docClient.query(params, function(err, data) {
    
            if(err) { 
                console.log('lookupTop: Error - ' + err.toString());
                 speechOutput = 'Sorry, an error occurred looking that up.';
                 shouldEndSession = true;
            } else { 
                if (!data || data.Items.length===0) {
                    speechOutput = 'Sorry, I found no data. ' + Tag;
                }
                else {
                    try {
                        response = replace(response, '{country1}', data.Items[0].name);
                        response = replace(response, '{country2}', data.Items[1].name);
                        response = replace(response, '{country3}', data.Items[2].name);
                        response = replace(response, '{end}', Tag);
                        speechOutput = response;
                    }
                    catch(e) {
                        console.log('lookupTop: Exception - ' + e.toString());
                        speechOutput = "Sorry, I had a problem looking that up.";
                    }
                }
                    
            // Setting repromptText to null signifies that we do not want to reprompt the user.
            // If the user does not respond or says something that is not understood, the session
            // will end.
            callback(sessionAttributes, buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            }
      });
    }
}
listTop Function

The response to Which countries are largest? is The countries with the largest area are Russia, Antarctica, and Canada.


buildSpeechletResponse


We've made several references to a buildSpeechletResponse function, which assembles the response to send back to Alexa. Here's the code to buildSpeechletResponse:

// --------------- Helpers that build all of the responses -----------------------

function buildSpeechletResponse(title, output, repromptText, shouldEndSession) {
    
    var outputSpeech = null;
    
    var cardText = replace(output, '<break time="1s"/>', '\r\n\r\n');
    var pos = cardText.indexOf('<audio');
    if (pos != -1) cardText = 'Playing National Anthem\r\n\r\nWhat else would you like to know?';
    
    if (output.indexOf('<') != -1) {
        outputSpeech = {   // output contains markup (audio, breaks) - output SSML
            type: 'SSML',
            ssml: '<speak>' + output + '</speak>',
        };   
    }
    else {
        outputSpeech = {  // output is just text
            type: 'PlainText',
            text: output
        };
    }

    return {
        outputSpeech: outputSpeech,
        card: {
            type: 'Standard',
            title: `${title}`,
            text: cardText,
            content: `SessionSpeechlet - ${output}`,
            image: {
                "smallImageUrl": FlagImageUrl,
                "largeImageUrl": MapImageUrl
            }
        },
        reprompt: {
            outputSpeech: {
                type: 'PlainText',
                text: repromptText,
            },
        },
        shouldEndSession,
    };
}
buildSpeechletResponse Function

The above code assembles a repsonse that includes an outputSpeech object. In the original sample used as a starting point for this project, outSpeech was just text with a type of 'PlainText'. However, our responses sometimes include <break time="1s"> markup to pause a second; and <audio ...=""> tags to play national anthems. For these reason our response is of type SSML

Certification

Having put the work into creating this skill, I decided to submit it for certification so anyone could use it. This was my first time going through the certification process, and I was pleased to find it a smooth process.

The first step was to polish the voice app as much as I could. Alexa voice apps are easy to get started, but getting them to a good production-ready state is another thing; it requires thinking through all the different ways someone might express an intent and a lot of testing. In particular, it requires getting input from multiple people since you won't think of everything yourself. After convincing my family to assist me with testing, I felt I was ready for my first submission.

The Amazon developer console takes you through a Distribution area for describing your app and answering some questions about it; you can then run a validation and automated test to pick up some low-hanging fruit about areas that need attention. When you're past all that you can submit for certification, then sit back and await feedback email. I submitted my first attempt on a Sunday evening and when I went to my computer Monday morning feedback was waiting for me. There were just two very reasonable issues to address, explained in a helpful way.

One issue had to do with my responses. I'd designed the app to stay open until you expressly tell it to exit; the reasoning being that if you're getting country facts, you probably want to ask a series of questions. The feedback said I could only do that if my responses prompted the user for something more. That was easy to address: now when a question is answered, there's a one-second pause followed by "What else would you like to know?"

The second issue had to do with cards, which is what Alexa displays when used from a device with a display (such as my TV with Amazon FireStick). The default sample app I used as a starting point output very technical titles like 'SpeeachApplet'. I overhaued the card output, and now a card is display that includes a friendly-worded title; a map of the country being described (or its flag on small-size display); and the text of the response. Here for example is the card displayed in response to Where is French Polynesia?

Card Response to "Where is French Polynesia?"

I resubmitted Monday mid-morning, and pass certification overnight. All in all, a satisfying certification process. Here's what the skill listing looks like on Amazon.com:


In Conclusion

In this series, I showed how public-domain data from the CIA World Factbook can be hosted on Amazon Web Services. After bringing that data into DynamoDB and creating a Lambda Function serverless API, and then creating a web site, we tackled an Alexa Skill in this final part of the series. You can access the skill with "Alexa, Open World Country Data".

Our skill was able to answer questions about specific values for a country, such as its population or area; as well as lists such as the languages spoken in a country. Country ranking inquiries can also be made, such as which countries have the lowest exports or highest inflation. Although we covered many data points, there's a great deal more that can be mined out of this data source.


Tuesday, January 22, 2019

Ghost Game, Part 2: Voice-Enabling with Alexa

Note: If you don't see proper syntax highlighting of code, change https to http in URL.

I few weeks ago, I wrote about a father-daugher project in which I and my college student daughter Susan collaborated on a web site implementing the game of GHOST. This is one of those games your family might have played when you were in the car or sitting around the table or a campfire: you say letters in turn until somebody makes a word, and then they lose the round. We'd next planned to have her design a user experience during Christmas break, but we didn't get around to it. This month, I got the idea to voice-enable Ghost for Amazon Alexa (accessible on the Echo, Echo Dot, FireStick, the Alexa App on phones, and lots of other places).

I had been looking for an excuse to create a voice-assistant application for Alexa, and decided a speech front-end for GHOST would be the perfect project since the back-end already existed. This post describes how I went about creating my Alexa Skill (as they're called), and how it turned out. I have not published my game--it isn't fully debugged yet, and there is already a Ghost Game skill on the Alexa marketplace (not mine). My main purpose was learning how to do an Alexa Skill, and that's what I accomplished.

The End Result

Before we go into how I created the skill, let's take a look at the end result. Below are some audio clips of actual gameplay on Alexa. Take a listen.

Session 1: Human makes word M-E-M-O-R-Y
Session 2: Computer bluffs, challenged by human. Human bluffs, challenged by computer.
Session 3: Human makes a word game doesn't know, teaches it a new word.
Session 4: Full game

Here's a visual rendering of a dialog with the game (bolded words spoken by human).

Human: Alexa, open Ghost David.
Alexa: Welcome to Ghost. To hear the rules, say help. I'll start. "M".
Human: "E"
Alexa; "M"
Human: "O"
Alexa: "R."
Human: "Y"
Alexa: "Memory. That's a word! I win. Youre a G. Let's start a new game. You go first."

Human: "T"
Alexa: "A"
Human: "R"
Alexa: "I challenge you. What's your word?"
Human: "My word was 'tarnish'."
Alexa: I see. You win, and I'm a G. Congratulations. Should I add this word to my dictionary? Tarnish. T-A-R-N-I-S-H
Human: Add Word
Alexa: Thanks. I have learned the word 'tarnish'.
Alexa: Let's start a new game. You can start.

Adapting a Web Site to be an Alexa Skill

Our GHOST web site had a pretty simple user interface: the word-in-progress letters were displayed. Users could type a letter to take a turn, and also had a few buttons they could click such as I Challenge You, That's a Word, and New Game. This should translate pretty readily to spoken phrases:

  • Instead of typing a letter to take a turn, the user will speak a letter: "C"
  • Instead of clicking a That's a Word button, the user will say "That's a word."
  • Instead of clicking the I Challenge You button, the user can say "I challenge you."
  • Instead of clicking the New Game button, the user can say "New Game."
Since this is a spoken interface, we'll also add some new action phrases:
  • If the user forgets what the current word is, they can ask "What is word?"
  • If the user forgets the rules for Ghost, they can say "help" or "What are the rules?"
And, when the computer challenge the human, we'll need to process their response:
  • They state their word: "My word was tofu."
  • They admit they were bluffing: "I was bluffing" or "you got me."

Architecture

In terms of the components we need to create, an Alexa Custom Skill usually has two (or more) parts:
  1. There's the skill defnition, which you create and configure in the Amazon Developer portal. This includes:
    • invocation name: official name of skill used to open it.
    • utterances: speech patterns to recognize.
    • intents: groups of utterances that express the same intended action.
    • slots: variables of various types that can occur in intents. You can define your own custom slot types by providing a list of values--such as letters of the alphabet.
  2. A cloud service, usuallly implemented as an AWS Lambda Function (a server-less computing component). This can be written in a variety of languages, including Node.js (JavaScript) or Python. 
  3. Lastly, your cloud service may in turn need to talk to something in the outside world, such as an API or service. For us, that's the existing back-end server functions of the Ghost web site.
In the case of Ghost, these components are:
  1. A skill definition named Ghost, invoked with the name "Ghost David".
  2. A Node.js AWS Lambda function. This function has to do the same things the Ghost web site's front-end does: implement the game logic and make calls to the back end server. The JavaScript logic that was originally in the Ghost web site's front end is replicated here. 
  3. The original Ghost web site's server will respond to the Lambda function, providing functionality such as playing a turn; checking the validity of a word; responding to a player challenge; and adding new words. The server queries and updates the word list SQL Server database. The Lambda function will be calling actions like /Home/Play/word, /Home/ValidWord/word, and /Home/Challenge/word.
Architecture: Alexa and GHOST Web Site Share a Common Back-End

This entire skill was written in Amazon portal interfaces: the Skill definition in the developer portal, and the JavaScript code for the Lambda Function in the AWS portal. No desktop tools required!

Skill Definition

A skill definition is configured in the Amazon developer portal. In the Intents section you specify intended user actions. Some of the intents for Ghost are "My Letter Is...", "I Challenge You", and "That's a Word".

Skills Developer Portal

For each intent, you can specify a collection of utterances. This allows you to cover variations in what a user might say. Your utterances can contain variable placeholder such as {Letter}, called slots. A slot is passsed to the Lambda Function. Thus, when someone says "My letter is B" to Ghost, that utterance will be recognized as intent MyLetterIsIntent. That will be passed to the back end with slot Letter set to "B". The back end will then have the opportunity to exercise logic and decide on a suitable response.

Intent

These slots can be of different types. There are many pre-defined slot types, such as a date or city. However, you can also define your own custom slots, where you provide a list of values. That is what was done for the Letter slot. The values define include not only A-Z, but also Alpha-Zulu: the NATO alphabet, a useful fallback if Alexa can't understand a single letter. Thus, I can say "B" or "Bravo": both will be understood as B by the Lambda Function.

Slot Values

On the Endpoint page, the skill is linked to the AWS Lambda Function. The Lambda function's ARN ID is stored in the skill's configuration. You can also associate the skill ID in the Lambda function's configuration which is recommended.

Skill Endpoint 

Lambda Function

The Lambda Function has functions for announcing a welcome message, and for responding to intents. Let's look at some of them.

The method getWelcomeResponse provides the initial spoken prompts when the game is started. We also create a structure of session variables here: we will need to maintain state throughout the sesson in order to track the word-in-play, who went first, and player scores. In this function the computer also takes the first turn, so there is an HTTP call to the server to get a letter to play. The letter is included in the spoken response. A callback passes the response back to the skill, and the user hears something like the following (the initial letter is random each time):

"Welcome to Ghost. To hear the rules, say help. I'll start. P."
// --------------- Functions that control the skill's behavior -----------------------
// getWelcomeResponse: intiial greeting to user. Initialize session attributes. Make first turn.

function getWelcomeResponse(callback) {
    // If we wanted to initialize the session to have some attributes we could add those here.
    let sessionAttributes = {
        inChallenge: false,  // true if in a challenge.
        gameInSession: true, // true if a game is in-progress.
        turn: true,          // true = human player turn, false = computer player turn
        word: '',            // current partial word in play.
        saveWord: '',        // saved word before last player move
        startPlayer: "0",    // 0: computer player starts, 1: human player starts.
        humanGhost: 0,       // human player's loss count toward being a G-H-O-S-T.
        computerGhost: 0,    // computer player's loss count toward being a G-H-O-S-T.
        unrecognizedLetterCount: 0, // number of times we've failed to understand the human's spoken letter.
        newWord: ''          // new word we learned from human
    };
    const cardTitle = 'Welcome';
    sessionAttributes.startPlayer = "0"; // 0: computer goes first, 1: human goes first
    var speechOutput = 'Welcome to Ghost. To hear the rules, say help. I\'ll start. ';
    var repromptText = 'Say a letter';
    var shouldEndSession = false;
    
     httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (letter) => { 
         letter = trimLetter(letter);
         if (letter != null) {
             speechOutput = speechOutput + '"' + letter + '".';
             sessionAttributes.word = sessionAttributes.word + letter;
        }

        callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
     });
}

One important method is onIntent, whose purpose is to invoke a handler function for an intent based its name. For example, the intent type MyLetterIsIntent is handled by the function humanPlay.
/**
 * Called when the user specifies an intent for this skill.
 */
function onIntent(intentRequest, session, callback) {
    console.log(`onIntent requestId=${intentRequest.requestId}, sessionId=${session.sessionId}`);

    const intent = intentRequest.intent;
    const intentName = intentRequest.intent.name;

    // Dispatch to your skill's intent handlers
    if (intentName === 'MyLetterIsIntent') {
        humanPlay(intent, session, callback);
    } else if (intentName === 'WhatIsWordIntent') {
        whatIsWord(intent, session, callback);
    } else if (intentName === 'ThatsAWordIntent') {
        thatsAWord(intent, session, callback);
    } else if (intentName==='NewGameIntent') {
        newGame(intent, session, callback);
    } else if (intentName==='IChallengeYouIntent') {
        humanChallenge(intent, session, callback);
    } else if (intentName==='MyWordIsIntent') {
        humanMyWordIs(intent, session, callback);
    } else if (intentName==='UndoIntent') {
        undoIntent(intent, session, callback);
    } else if (intentName==='BluffingIntent') {
        humanBluffing(intent, session, callback);
    } else if (intentName==='AddNewWordIntent') {
        addNewWord(intent, session, callback);
    } else if (intentName==='HelpIntent') {
        helpIntent(intent, session, callback);
    } else if (intentName==='DebugIntent') {
        debugIntent(intent, session, callback);
    } else if (intentName === 'AMAZON.HelpIntent') {
        getWelcomeResponse(callback);
    } else if (intentName === 'AMAZON.StopIntent' || intentName === 'AMAZON.CancelIntent') {
        handleSessionEndRequest(callback);
    } else if (intentName === 'AMAZON.FallbackIntent') {
         handleFallbackIntent(intent, session, callback)
    } else {
        console.log('intentName: ' + intentName);
        throw new Error('Invalid intent');
    }
}

the humanPlay function is one of the larger functions. It handles the human game play, where the human has spoken a letter. The letter can come in a few different ways, including a NATO alphabet word, so a function named trimLetter is employed to identify the letter and return a value like "A", "B", etc. The letter is added to the current word, and then evaluated.

If the user has made a valid word, determined by asking the server to check its word list, then the human has lost. Their GHOST count is incremented, and they are told they are now a G, or a G-H, etc. A new round starts. 

        "Stone. That's a word. I win! Let's start a new game. You go first."

Otherwise, the computer attempts to play, by passing the current word to the server and asking it to play. If the response is a letter, it is played (spoken) and added to the current word. If however the server responds with "?", a challenge is issued.
// -------------------- Human Plays Letter --------------------
// A letter was played. Set letter in session and prepares speech to reply to the user.

function humanPlay(intent, session, callback) {
    const cardTitle = intent.name;
    const letterSlot = intent.slots.Letter;
    let sessionAttributes = session.attributes;

    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    sessionAttributes.saveWord = sessionAttributes.word;
    console.log('humanPlay 02 saveWord: ' + sessionAttributes.saveWord);

    if (!sessionAttributes.gameInSession) {
        speechOutput = "To start a new game, say 'New Game'.";
        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
        return;
    }

    if (sessionAttributes.inChallenge) {
        speechOutput = "Please respond to the challenge. Either tell me what your word was, or admit you were bluffing. Or say 'New Game' to start a new game..";
        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
        return;
    }

    if (letterSlot) {
        var letter = trimLetter(letterSlot.value);
        
        if (letter===null) {
            speechOutput = "I'm not sure what your letter is.";
            sessionAttributes.unrecognizedLetterCount++;
            if (sessionAttributes.unrecognizedLetterCount==2) 
                speechOutput += " Please try again, or say a word from the NATO alphabet like Alpha or Bravo.";
            else
                speechOutput += " Please try again.";
            callback(sessionAttributes,
                 buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
             return;
        }
        else {
            console.log('Human played: ' + letter + ' | current word: ' + sessionAttributes.word);
            sessionAttributes.unrecognizedLetterCount = 0;
            //sessionAttributes = createLetterAttributes(letter);
            sessionAttributes.word = sessionAttributes.word + letter;

            // Human play - check for a completed word
            
            if (sessionAttributes.word.length >= 3) {
                httpGet('Home/ValidWord/' + sessionAttributes.word,  (res) => {
                    if (res=='true') {
                        sessionAttributes.humanGhost++;
                        speechOutput = sessionAttributes.word + ". That\'s a word! I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
                         sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
                        sessionAttributes.word = '';
                        callback(sessionAttributes,
                            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
                        return;
                    }
                });
            }
    
            // Computer play
    
            httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (res) => {
                
                res = trimLetter(res);
                     
                if (res==='?') {   // challenge
                    speechOutput = 'I challenge you! What\'s your word?';
                }
                else {
                    console.log('Computer played: ' + letter + ' | current word: ' + sessionAttributes.word);
                    speechOutput = speechOutput + '"' + res + '".';
                    sessionAttributes.word = sessionAttributes.word + res;
                }
    
                // If the user either does not reply to the welcome message or says something that is not
                // understood, they will be prompted again with this text.
    
                callback(sessionAttributes,
                    buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
                return;
             });
        }
    } else {
        speechOutput = "There is a data problem in the skill: letter slot not found.";

        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    }
}

humanPlay function

The user has other options besides playing a letter. The human can say "I challenge you", in which case the humanChallenge function is run. If the program had a valid word, it will spell it out and score a loss for the human. Otherwise, it will admit it was bluffing and score a loss for itself.
// -------------------- Human Challenges Computer --------------------
// Human challenges computer. Tell them our word, or admit we were bluffing.

function humanChallenge(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    httpGet('Home/Challenge/' + sessionAttributes.word,  (res) => {
        if (res==='^' || res==='"^"') {
            sessionAttributes.computerGhost++;
            speechOutput ="You got me - I was bluffing. That makes me a " + score(sessionAttributes.computerGhost) + ". Let's start a new game. You go first.";
            sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
            sessionAttributes.word = '';
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            return;
        }
        else {
            sessionAttributes.humanGhost++;
            speechOutput = "My word was " + res + ". " + spellWord(res) + ". I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
            sessionAttributes.startPlayer = "1";
            sessionAttributes.word = '';
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            return;
        }
    });

}

humanChallenge function

Test and Debug

How do you test this kind of application? From the Developer Portal, there's an Alexa Simulator; but if you have an actual device on the same account then you'll be able to call up your skill even though it hasn't been officially published.

How do you debug an Alexa Skill? Well, all you have to do is add console.log(text); statements in your Lambda Function's JavaScript. The output will be captured in CloudWatch logs. Every time you conduct an Alexa Session, there's a CloudWatch log you can inspect. These logs are also where error detail will be found if your Lambda Function has an unhandled exception. In the log excerpt below, the "Human played..." and "Computer played..." lines were added by console.log statements.

CloudWatch Logs


Post-Mortem: Rapid development, Prolonged Debugging

Ghost was my very first attempt at developing an Alexa skill. To give you some idea of how rapidly you can learn Alexa Skills, here's what I experienced in my first 3 days:

1. Saturday afternoon. I went to Best Buy and purchased an Echo Dot for $24.
2. I went through the Color Picker quickstart.  I registered as a developer with Amazon. Some of the instructions in the quickstart were hard to follow, because some of the developer site screens and AWS screens had changed since the tutorial was written. But I stayed with it, and in a couple of hours I was able to say "Open Color Picker" to my Echo Dot and tell it my favorite color. I chose the Node.js version (JavaScript), but there are also versions of this quickstart where the Lambda Function is written in other languages such as Python.
3. Now feeling empowered, I worked on a derivative of Color Picker that would search my blog. To achieve that, I had to look up how to do an HTTP request from a Node.js Lambda function. In another couple of hours, I had this project working. I now felt ready and empowered to do something real.
4. On Sunday, I started work on my Ghost Alexa skill, again using Color Picker as my starting point.
5. By end of day Monday, I was finished with the basic implementation: I could play Ghost with my Echo Dot. All that remains is thorough testing and debug.

If you're going to work on an Alexa Skill of any depth, you're likely going to find like me that initial development goes pretty quickly, but you can expect to spend quite a bit of time on test and debug before your skill is ready for prime time. There are a number of reasons for this, chief of which is the surprise factor: the user may not say things you expect them to, or what they say may be misunderstood. It took some time to learn the nuances of  effectively communicating this way (you might call this "elastic communication"). If you want your Alexa Skill to be smarter than a voicemail menu, then you'll need to invest some effort--but it will be worth it in the usability of what you end up with.  All in all, I'm pleased with how quickly I became functional in this new area.

Will I publish Ghost? Perhaps, after I finish debugging and refining it.

Source Code

Below is the full source code to the Alexa Skill and the Node.js Lambda Function.

Alexa Skill

{
    "interactionModel": {
        "languageModel": {
            "invocationName": "ghost david",
            "intents": [
                {
                    "name": "AMAZON.FallbackIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.CancelIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.HelpIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.StopIntent",
                    "samples": [
                        "exit game",
                        "exit",
                        "stop game",
                        "quit game",
                        "stop",
                        "quit",
                        "end game"
                    ]
                },
                {
                    "name": "AMAZON.NavigateHomeIntent",
                    "samples": []
                },
                {
                    "name": "MyLetterIsIntent",
                    "slots": [
                        {
                            "name": "Letter",
                            "type": "LIST_OF_LETTERS"
                        }
                    ],
                    "samples": [
                        "Let's try {Letter}",
                        "{Letter}",
                        "My letter is {Letter}"
                    ]
                },
                {
                    "name": "WhatIsWordIntent",
                    "slots": [],
                    "samples": [
                        "What is the current word",
                        "What is current word",
                        "what's the word again",
                        "what's the word so far",
                        "tell me word",
                        "What's the current word",
                        "What's the word",
                        "Say word",
                        "What is the word",
                        "What is word"
                    ]
                },
                {
                    "name": "ThatsAWordIntent",
                    "slots": [],
                    "samples": [
                        "You lose",
                        "That is a word",
                        "I win",
                        "You made a word",
                        "Tha'ts a word"
                    ]
                },
                {
                    "name": "NewGameIntent",
                    "slots": [],
                    "samples": [
                        "redo",
                        "start over",
                        "start new game",
                        "new game"
                    ]
                },
                {
                    "name": "IChallengeYouIntent",
                    "slots": [],
                    "samples": [
                        "challenge",
                        "Tell me your word",
                        "What is your word",
                        "I give up",
                        "I challenge you"
                    ]
                },
                {
                    "name": "MyWordIsIntent",
                    "slots": [
                        {
                            "name": "ClaimedWord",
                            "type": "ANY_WORD"
                        }
                    ],
                    "samples": [
                        "My word was {ClaimedWord}",
                        "My word is {ClaimedWord}"
                    ]
                },
                {
                    "name": "DebugIntent",
                    "slots": [],
                    "samples": [
                        "log debug",
                        "log variables",
                        "debug log"
                    ]
                },
                {
                    "name": "UndoIntent",
                    "slots": [],
                    "samples": [
                        "do over",
                        "take it back",
                        "undo my letter",
                        "change my letter",
                        "change my last turn",
                        "backspace",
                        "undo",
                        "undo my last turn"
                    ]
                },
                {
                    "name": "HelpIntent",
                    "slots": [],
                    "samples": [
                        "i need the rule",
                        "explain",
                        "what do I do now",
                        "what do I do",
                        "what are the rules",
                        "what commands do you know",
                        "help"
                    ]
                },
                {
                    "name": "BluffingIntent",
                    "slots": [],
                    "samples": [
                        "busted",
                        "Oops",
                        "I have no word",
                        "I had no word",
                        "You caught me I was bluffing",
                        "You caught me",
                        "I didn't have a word",
                        "You got me I was bluffing",
                        "You got me",
                        "I was bluffing"
                    ]
                },
                {
                    "name": "AddNewWordIntent",
                    "slots": [],
                    "samples": [
                        "add word",
                        "add new word",
                        "yes add new word",
                        "yes add word",
                        "yes"
                    ]
                }
            ],
            "types": [
                {
                    "name": "LIST_OF_LETTERS",
                    "values": [
                        {
                            "name": {
                                "value": "zulu"
                            }
                        },
                        {
                            "name": {
                                "value": "yankee"
                            }
                        },
                        {
                            "name": {
                                "value": "x-ray"
                            }
                        },
                        {
                            "name": {
                                "value": "whiskey"
                            }
                        },
                        {
                            "name": {
                                "value": "victor"
                            }
                        },
                        {
                            "name": {
                                "value": "uniform"
                            }
                        },
                        {
                            "name": {
                                "value": "tango"
                            }
                        },
                        {
                            "name": {
                                "value": "sierra"
                            }
                        },
                        {
                            "name": {
                                "value": "romeo"
                            }
                        },
                        {
                            "name": {
                                "value": "quebec"
                            }
                        },
                        {
                            "name": {
                                "value": "papa"
                            }
                        },
                        {
                            "name": {
                                "value": "oscar"
                            }
                        },
                        {
                            "name": {
                                "value": "november"
                            }
                        },
                        {
                            "name": {
                                "value": "mike"
                            }
                        },
                        {
                            "name": {
                                "value": "lima"
                            }
                        },
                        {
                            "name": {
                                "value": "kilo"
                            }
                        },
                        {
                            "name": {
                                "value": "juliett"
                            }
                        },
                        {
                            "name": {
                                "value": "india"
                            }
                        },
                        {
                            "name": {
                                "value": "hotel"
                            }
                        },
                        {
                            "name": {
                                "value": "golf"
                            }
                        },
                        {
                            "name": {
                                "value": "foxtrot"
                            }
                        },
                        {
                            "name": {
                                "value": "echo"
                            }
                        },
                        {
                            "name": {
                                "value": "delta"
                            }
                        },
                        {
                            "name": {
                                "value": "alpha"
                            }
                        },
                        {
                            "name": {
                                "value": "charlie"
                            }
                        },
                        {
                            "name": {
                                "value": "bravo"
                            }
                        },
                        {
                            "id": "LETTER_Z",
                            "name": {
                                "value": "z",
                                "synonyms": [
                                    "zulu"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_Y",
                            "name": {
                                "value": "y",
                                "synonyms": [
                                    "Yankee"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_X",
                            "name": {
                                "value": "x",
                                "synonyms": [
                                    "X-Ray"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_W",
                            "name": {
                                "value": "w",
                                "synonyms": [
                                    "Whiskey"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_V",
                            "name": {
                                "value": "v",
                                "synonyms": [
                                    "Victor"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_U",
                            "name": {
                                "value": "u",
                                "synonyms": [
                                    "Uniform"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_T",
                            "name": {
                                "value": "t",
                                "synonyms": [
                                    "Tango"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_S",
                            "name": {
                                "value": "s",
                                "synonyms": [
                                    "Sierra"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_R",
                            "name": {
                                "value": "r",
                                "synonyms": [
                                    "Romeo"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_Q",
                            "name": {
                                "value": "q",
                                "synonyms": [
                                    "Quebec"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_P",
                            "name": {
                                "value": "p",
                                "synonyms": [
                                    "Papa"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_O",
                            "name": {
                                "value": "o",
                                "synonyms": [
                                    "Oscar"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_N",
                            "name": {
                                "value": "n",
                                "synonyms": [
                                    "November"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_M",
                            "name": {
                                "value": "m",
                                "synonyms": [
                                    "Mike"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_L",
                            "name": {
                                "value": "l",
                                "synonyms": [
                                    "Lima"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_K",
                            "name": {
                                "value": "k",
                                "synonyms": [
                                    "Kilo"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_J",
                            "name": {
                                "value": "j",
                                "synonyms": [
                                    "Juliett"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_I",
                            "name": {
                                "value": "i",
                                "synonyms": [
                                    "India"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_H",
                            "name": {
                                "value": "h",
                                "synonyms": [
                                    "Hotel"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_G",
                            "name": {
                                "value": "g",
                                "synonyms": [
                                    "Golf"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_F",
                            "name": {
                                "value": "f",
                                "synonyms": [
                                    "Foxtrot"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_E",
                            "name": {
                                "value": "e",
                                "synonyms": [
                                    "Echo"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_D",
                            "name": {
                                "value": "d",
                                "synonyms": [
                                    "Delta"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_C",
                            "name": {
                                "value": "c",
                                "synonyms": [
                                    "Charlie"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_B",
                            "name": {
                                "value": "b",
                                "synonyms": [
                                    "Bravo"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_A",
                            "name": {
                                "value": "a",
                                "synonyms": [
                                    "alpha"
                                ]
                            }
                        }
                    ]
                },
                {
                    "name": "ANY_WORD",
                    "values": [
                        {
                            "name": {
                                "value": "Alpha"
                            }
                        }
                    ]
                }
            ]
        }
    }
}

Lambda Function

'use strict';
var http = require('http'); 

//---------- G H O S T ----------
// Ghost game AWS Lambda function. This function pairs with the Alexa skill David Ghost. 

// The Ghost game for Alexa has 3 components:
// 1. An Alexa skill named Ghost David (unpublished).
// 2. AWS Lambda function (this code), which takes the place of the web site JavaScript at http://ghost-susanpallmann.com.
// 3. The ASP.NET MVC web site back-end at http://ghost-susanpallmann.com. see http://davidpallmann.blogspot.com/2018/12/ghost-father-daughter-project-part-1.html
//
// To output to the CloudWatch logs for this function, simply add console.log('message'); statements to the code.

/**
 * This sample demonstrates a simple skill built with the Amazon Alexa Skills Kit.
 * The Intent Schema, Custom Slots, and Sample Utterances for this skill, as well as
 * testing instructions are located at http://amzn.to/1LzFrj6
 *
 * For additional samples, visit the Alexa Skills Kit Getting Started guide at
 * http://amzn.to/1LGWsLG
 */


// --------------- Helpers that build all of the responses -----------------------

function buildSpeechletResponse(title, output, repromptText, shouldEndSession) {
    return {
        outputSpeech: {
            type: 'PlainText',
            text: output,
        },
        card: {
            type: 'Simple',
            title: `SessionSpeechlet - ${title}`,
            content: `SessionSpeechlet - ${output}`,
        },
        reprompt: {
            outputSpeech: {
                type: 'PlainText',
                text: repromptText,
            },
        },
        shouldEndSession,
    };
}

function buildResponse(sessionAttributes, speechletResponse) {
    return {
        version: '1.0',
        sessionAttributes,
        response: speechletResponse,
    };
}


// --------------- Functions that control the skill's behavior -----------------------

// getWelcomeResponse: intiial greeting to user. Initialize session attributes. Make first turn.

function getWelcomeResponse(callback) {
    // If we wanted to initialize the session to have some attributes we could add those here.
    let sessionAttributes = {
        inChallenge: false,  // true if in a challenge.
        gameInSession: true, // true if a game is in-progress.
        turn: true,          // true = human player turn, false = computer player turn
        word: '',            // current partial word in play.
        saveWord: '',        // saved word before last player move
        startPlayer: "0",    // 0: computer player starts, 1: human player starts.
        humanGhost: 0,       // human player's loss count toward being a G-H-O-S-T.
        computerGhost: 0,    // computer player's loss count toward being a G-H-O-S-T.
        unrecognizedLetterCount: 0, // number of times we've failed to understand the human's spoken letter.
        newWord: ''          // new word we learned from human
    };
    const cardTitle = 'Welcome';
    sessionAttributes.startPlayer = "0"; // 0: computer goes first, 1: human goes first
    var speechOutput = 'Welcome to Ghost. To hear the rules, say help. I\'ll start. ';
    var repromptText = 'Say a letter';
    var shouldEndSession = false;
    
     httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (letter) => { 
         letter = trimLetter(letter);
         if (letter != null) {
             speechOutput = speechOutput + '"' + letter + '".';
             sessionAttributes.word = sessionAttributes.word + letter;
        }

        callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
     });
}


// -------------------- Human Plays Letter --------------------
// A letter was played. Set letter in session and prepares speech to reply to the user.

function humanPlay(intent, session, callback) {
    const cardTitle = intent.name;
    const letterSlot = intent.slots.Letter;
    let sessionAttributes = session.attributes;

    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    sessionAttributes.saveWord = sessionAttributes.word;
    console.log('humanPlay 02 saveWord: ' + sessionAttributes.saveWord);

    if (!sessionAttributes.gameInSession) {
        speechOutput = "To start a new game, say 'New Game'.";
        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
        return;
    }

    if (sessionAttributes.inChallenge) {
        speechOutput = "Please respond to the challenge. Either tell me what your word was, or admit you were bluffing. Or say 'New Game' to start a new game..";
        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
        return;
    }

    if (letterSlot) {
        var letter = trimLetter(letterSlot.value);
        
        if (letter===null) {
            speechOutput = "I'm not sure what your letter is.";
            sessionAttributes.unrecognizedLetterCount++;
            if (sessionAttributes.unrecognizedLetterCount==2) 
                speechOutput += " Please try again, or say a word from the NATO alphabet like Alpha or Bravo.";
            else
                speechOutput += " Please try again.";
            callback(sessionAttributes,
                 buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
             return;
        }
        else {
            console.log('Human played: ' + letter + ' | current word: ' + sessionAttributes.word);
            sessionAttributes.unrecognizedLetterCount = 0;
            //sessionAttributes = createLetterAttributes(letter);
            sessionAttributes.word = sessionAttributes.word + letter;

            // Human play - check for a completed word
            
            if (sessionAttributes.word.length >= 3) {
                httpGet('Home/ValidWord/' + sessionAttributes.word,  (res) => {
                    if (res=='true') {
                        sessionAttributes.humanGhost++;
                        speechOutput = sessionAttributes.word + ". That\'s a word! I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
                         sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
                        sessionAttributes.word = '';
                        callback(sessionAttributes,
                            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
                        return;
                    }
                });
            }
    
            // Computer play
    
            httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (res) => {
                
                res = trimLetter(res);
                     
                if (res==='?') {   // challenge
                    speechOutput = 'I challenge you! What\'s your word?';
                }
                else {
                    console.log('Computer played: ' + letter + ' | current word: ' + sessionAttributes.word);
                    speechOutput = speechOutput + '"' + res + '".';
                    sessionAttributes.word = sessionAttributes.word + res;
                }
    
                // If the user either does not reply to the welcome message or says something that is not
                // understood, they will be prompted again with this text.
    
                callback(sessionAttributes,
                    buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
                return;
             });
        }
    } else {
        speechOutput = "There is a data problem in the skill: letter slot not found.";

        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    }
}

// Return human or computer loss score as a phrase in the form of G-H-O...

function score(ghostScore) {
    switch(ghostScore) {
        case 0:
            return "";
        case 1:
            return "G";
        case 2:
            return "G-H";
        case 3:
            return "G-H-O";
        case 4:
            return "G-H-O-S";
        default:
        case 5:
            return "G-H-O-S-T";
    }
}


// --------------- Start New Game -----------------------

function newGame(intent, session, callback) {
    // If we wanted to initialize the session to have some attributes we could add those here.
    let sessionAttributes = session.attributes;
    const cardTitle = 'Welcome';
    sessionAttributes.startPlayer = "0"; // 0: computer goes first, 1: human goes first
    sessionAttributes.word = '';
    var speechOutput = 'New game. I\'ll start. ';
    var repromptText = 'Say a letter';
    var shouldEndSession = false;
    
     httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (letter) => { 
         
         letter = trimLetter(letter);
         
         if (letter != null) {
             speechOutput = speechOutput + '"' + letter + '".';
             sessionAttributes.word = sessionAttributes.word + letter;
        }

        callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
     });
}


function handleSessionEndRequest(callback) {
    const cardTitle = 'Session Ended';
    const speechOutput = 'Thank you for playng Ghost. Be seeing you!';
    // Setting this to true ends the session and exits the skill.
    const shouldEndSession = true;

    callback({}, buildSpeechletResponse(cardTitle, speechOutput, null, shouldEndSession));
}

// -------------------- Human Asks for Word --------------------
// Tell the user the current word, by spelling it out. e.g. "The current word is S-T-R-E".

function whatIsWord(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    speechOutput = "The current word is: ";
    
    if (sessionAttributes.word === '') {
        speechOutput = speechOutput + ' empty';
    }
    else {
        for (var w = 0; w < sessionAttributes.word.length; w++) {
            speechOutput = speechOutput + sessionAttributes.word.substring(w, w+1) + '. ';
        }
    }

    callback(sessionAttributes,
         buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// Input not understood. Say something.

function handleFallbackIntent(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    speechOutput = "It\'s your turn to say a letter.";
    
    callback(sessionAttributes,
         buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// -------------------- Human Says Computer Made a Word --------------------
// Accept human's word for it, and accept a loss. TOOD: add new word to vocabulary.

function thatsAWord(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    sessionAttributes.computerGhost++;
    speechOutput = "Congratulations, you beat me. Now I'm a " + score(sessionAttributes.computerGhost) + ". Why don't you start a new word?";
    
    sessionAttributes.word = '';
    sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first

    callback(sessionAttributes,
         buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// -------------------- Human Challenges Computer --------------------
// Human challenges computer. Tell them our word, or admit we were bluffing.

function humanChallenge(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    httpGet('Home/Challenge/' + sessionAttributes.word,  (res) => {
        if (res==='^' || res==='"^"') {
            sessionAttributes.computerGhost++;
            speechOutput ="You got me - I was bluffing. That makes me a " + score(sessionAttributes.computerGhost) + ". Let's start a new game. You go first.";
            sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
            sessionAttributes.word = '';
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            return;
        }
        else {
            sessionAttributes.humanGhost++;
            speechOutput = "My word was " + res + ". " + spellWord(res) + ". I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
            sessionAttributes.startPlayer = "1";
            sessionAttributes.word = '';
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            return;
        }
    });

}


// -------------------- Human Challenges Computer --------------------
// Human admis they were bluffing after being challenged by computer. 

function humanBluffing(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    sessionAttributes.humanGhost++;
    speechOutput = "Sorry, you lose. You're now a " + score(sessionAttributes.humanGhost) + " Let's start a new game. You go first.";
    sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
    sessionAttributes.word = '';
    callback(sessionAttributes,
        buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}


// -------------------- Human Suppies Word upon Challenge --------------------
// Human claims they had a valid word after being challenged.

function humanMyWordIs(intent, session, callback) {
    const cardTitle = intent.name;
    var claimedWordSlot = intent.slots.ClaimedWord;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    if (claimedWordSlot && claimedWordSlot.value) {
        var claimedWord = claimedWordSlot.value;

        sessionAttributes.newWord = claimedWordSlot.value;
        
        if (sessionAttributes.newWord && sessionAttributes.word) {
            sessionAttributes.newWord = sessionAttributes.newWord.toUpperCase();
            var existingWord = sessionAttributes.word.toUpperCase();

            var index = sessionAttributes.newWord.indexOf(existingWord);
            if (index != 0) {
                speechOutput = "I'm sorry, but that word doesn't match the word in play. The word in play is " + spellWord(sessionAttributes.word) + ".";
            }
            else {
                if (claimedWord.toLowerCase()===sessionAttributes.word.toLowerCase()) {
                    sessionAttributes.humanGhost++;
                    speechOutput = sessionAttributes.word + "You just made a complete word so I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
                    sessionAttributes.word = '';
                    sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
                }
                else {
                    sessionAttributes.computerGhost++;
                    speechOutput = "I see. You win, and I'm a " + score(sessionAttributes.computerGhost) + ". Congratulations. Should I add this word to my dictionary? " + claimedWord + '. ' + spellWord(claimedWordSlot.value) + " Say 'Add Word' to confirm.";
                }
            }
        }
    }
    else {
        speechOutput = "Can you say your word again please?";
    }
    
    callback(sessionAttributes,
        buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// Human confirms, Yes add new word.

function addNewWord(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    if (sessionAttributes.newWord != '') {
        speechOutput = "Thanks. I have learned the word '" + sessionAttributes.newWord + "'. Let's start a new word. You can start.";
        sessionAttributes.startPlayer = "1";
        httpGet('Home/AddWord/' + sessionAttributes.newWord,  (letter) => { 
            sessionAttributes.newWord = '';
            sessionAttributes.word = '';
            sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
     });
    }
    else {
        speechOutput = "Sorry, I don\'t understand. Please say a letter.";
        callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    }
}


// Undo last turn (Alexa may have misunderstood letter)

function undoIntent(intent, session, callback) {
    
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    if (sessionAttributes.word==='') {
        speechOutput = "There\'s nothing to undo: the current word is empty.";
    }
    else {
        sessionAttributes.word = sessionAttributes.saveWord;

        speechOutput = "I undid your last turn. The current word is: " + spellWord(sessionAttributes.word);
    
    }
    callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// Help - explain the rules of Ghost and available commands.

function helpIntent(intent, session, callback) {
    
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    speechOutput = "Let's play Ghost! Each player takes a turn adding a letter. If you make a real word of 3 letters or more, you're a G. Next time that happens, you're a G-H. When you get to G-H-O-S-T you're out. If you don't think a valid word can be made, you can challenge the other player.";
    
    callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}


// Render speech phrase to spell out a word a letter a time.

function spellWord(word) {
    let speechOutput = '';
    if (word === '') {
        speechOutput = speechOutput + ' empty';
    }
    else {
        for (var w = 0; w < word.length; w++) {
            speechOutput = speechOutput + word.substring(w, w+1) + '. ';
        }
    }
    return speechOutput;
}

// output key variables to log

function debugIntent(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    console.log('sessionAttributes.word: ' + sessionAttributes.word + ', startPlayer: ' + sessionAttributes.startPlayer);

    speechOutput = "Variables have been logged";

    callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// Determine the letter from input that may be quoted or may be a NATO alphabetic code like Alpha / Bravo / etc. Return just the letter.
// NOTE: ? is recognized as a letter, as that response is given by the back end for a challenge during computer play.

function trimLetter(letter) {
    if (!letter) return null;
    letter = letter.toUpperCase();
    if (letter.indexOf('?') != -1) return '?';
    if (letter.indexOf('A') != -1) return 'A';
    if (letter.indexOf('B') != -1) return 'B';
    if (letter.indexOf('C') != -1) return 'C';
    if (letter.indexOf('D') != -1) return 'D';
    if (letter.indexOf('E') != -1) return 'E';
    if (letter.indexOf('F') != -1) return 'F';
    if (letter.indexOf('G') != -1) return 'G';
    if (letter.indexOf('H') != -1) return 'H';
    if (letter.indexOf('I') != -1) return 'I';
    if (letter.indexOf('J') != -1) return 'J';
    if (letter.indexOf('K') != -1) return 'K';
    if (letter.indexOf('L') != -1) return 'L';
    if (letter.indexOf('M') != -1) return 'M';
    if (letter.indexOf('N') != -1) return 'N';
    if (letter.indexOf('O') != -1) return 'O';
    if (letter.indexOf('P') != -1) return 'P';
    if (letter.indexOf('Q') != -1) return 'Q';
    if (letter.indexOf('R') != -1) return 'R';
    if (letter.indexOf('S') != -1) return 'S';
    if (letter.indexOf('T') != -1) return 'T';
    if (letter.indexOf('U') != -1) return 'U';
    if (letter.indexOf('V') != -1) return 'V';
    if (letter.indexOf('W') != -1) return 'W';
    if (letter.indexOf('X') != -1) return 'X';
    if (letter.indexOf('Y') != -1) return 'Y';
    if (letter.indexOf('Z') != -1) return 'Z';
    return null;
}

// --------------- Events -----------------------

/**
 * Called when the session starts.
 */
function onSessionStarted(sessionStartedRequest, session) {
    console.log(`onSessionStarted requestId=${sessionStartedRequest.requestId}, sessionId=${session.sessionId}`);
}

/**
 * Called when the user launches the skill without specifying what they want.
 */
function onLaunch(launchRequest, session, callback) {
    console.log(`onLaunch requestId=${launchRequest.requestId}, sessionId=${session.sessionId}`);

    // Dispatch to your skill's launch.
    getWelcomeResponse(callback);
}

/**
 * Called when the user specifies an intent for this skill.
 */
function onIntent(intentRequest, session, callback) {
    console.log(`onIntent requestId=${intentRequest.requestId}, sessionId=${session.sessionId}`);

    const intent = intentRequest.intent;
    const intentName = intentRequest.intent.name;

    // Dispatch to your skill's intent handlers
    if (intentName === 'MyLetterIsIntent') {
        humanPlay(intent, session, callback);
    } else if (intentName === 'WhatIsWordIntent') {
        whatIsWord(intent, session, callback);
    } else if (intentName === 'ThatsAWordIntent') {
        thatsAWord(intent, session, callback);
    } else if (intentName==='NewGameIntent') {
        newGame(intent, session, callback);
    } else if (intentName==='IChallengeYouIntent') {
        humanChallenge(intent, session, callback);
    } else if (intentName==='MyWordIsIntent') {
        humanMyWordIs(intent, session, callback);
    } else if (intentName==='UndoIntent') {
        undoIntent(intent, session, callback);
    } else if (intentName==='BluffingIntent') {
        humanBluffing(intent, session, callback);
    } else if (intentName==='AddNewWordIntent') {
        addNewWord(intent, session, callback);
    } else if (intentName==='HelpIntent') {
        helpIntent(intent, session, callback);
    } else if (intentName==='DebugIntent') {
        debugIntent(intent, session, callback);
    } else if (intentName === 'AMAZON.HelpIntent') {
        getWelcomeResponse(callback);
    } else if (intentName === 'AMAZON.StopIntent' || intentName === 'AMAZON.CancelIntent') {
        handleSessionEndRequest(callback);
    } else if (intentName === 'AMAZON.FallbackIntent') {
         handleFallbackIntent(intent, session, callback)
    } else {
        console.log('intentName: ' + intentName);
        throw new Error('Invalid intent');
    }
}

/**
 * Called when the user ends the session.
 * Is not called when the skill returns shouldEndSession=true.
 */
function onSessionEnded(sessionEndedRequest, session) {
    console.log(`onSessionEnded requestId=${sessionEndedRequest.requestId}, sessionId=${session.sessionId}`);
    // Add cleanup logic here
}

// Retrieve a web page from the server. Query passed in as a parameter.

function httpGet(query, callback) {

    var options = {
        host: 'ec2-18-224-78-19.us-east-2.compute.amazonaws.com',
        path: '/' + query,
        method: 'GET',
    };
    
    var req = http.request(options, res => {
        res.setEncoding('utf8');
        var responseString = "";
        
        //accept incoming data asynchronously
        res.on('data', chunk => {
            responseString = responseString + chunk;
        });
        
        //return the data when streaming is complete
        res.on('end', () => {
            //console.log('httpGet 02');
            //console.log(responseString);
            //console.log('httpGet 03');
            callback(responseString);
        });

    });
    req.end();
}



// --------------- Main handler -----------------------

// Route the incoming request based on type (LaunchRequest, IntentRequest,
// etc.) The JSON body of the request is provided in the event parameter.
exports.handler = (event, context, callback) => {
    try {
        console.log(`event.session.application.applicationId=${event.session.application.applicationId}`);

        /**
         * Uncomment this if statement and populate with your skill's application ID to
         * prevent someone else from configuring a skill that sends requests to this function.
         */
        /*
        if (event.session.application.applicationId !== 'amzn1.echo-sdk-ams.app.[unique-value-here]') {
             callback('Invalid Application ID');
        }
        */

        if (event.session.new) {
            onSessionStarted({ requestId: event.request.requestId }, event.session);
        }

        if (event.request.type === 'LaunchRequest') {
            onLaunch(event.request,
                event.session,
                (sessionAttributes, speechletResponse) => {
                    callback(null, buildResponse(sessionAttributes, speechletResponse));
                });
        } else if (event.request.type === 'IntentRequest') {
            onIntent(event.request,
                event.session,
                (sessionAttributes, speechletResponse) => {
                    callback(null, buildResponse(sessionAttributes, speechletResponse));
                });
        } else if (event.request.type === 'SessionEndedRequest') {
            onSessionEnded(event.request, event.session);
            callback();
        }
    } catch (err) {
        callback(err);
    }
};