Posts Tagged Google Assistant
Integrating Search Capabilities with Actions for Google Assistant, using GKE and Elasticsearch: Part 2
Posted by Gary A. Stafford in Cloud, GCP, Java Development, JavaScript, Serverless, Software Development on September 24, 2018
Voice and text-based conversational interfaces, such as chatbots, have recently seen tremendous growth in popularity. Much of this growth can be attributed to leading Cloud providers, such as Google, Amazon, and Microsoft, who now provide affordable, end-to-end development, machine learning-based training, and hosting platforms for conversational interfaces.
Cloud-based machine learning services greatly improve a conversational interface’s ability to interpret user intent with greater accuracy. However, the ability to return relevant responses to user inquiries, also requires interfaces have access to rich informational datastores, and the ability to quickly and efficiently query and analyze that data.
In this two-part post, we will enhance the capabilities of a voice and text-based conversational interface by integrating it with a search and analytics engine. By interfacing an Action for Google Assistant conversational interface with Elasticsearch, we will improve the Action’s ability to provide relevant results to the end-user. Instead of querying a traditional database for static responses to user intent, our Action will access a Near Real-time (NRT) Elasticsearch index of searchable documents. The Action will leverage Elasticsearch’s advanced search and analytics capabilities to optimize and shape user responses, based on their intent.
Action Preview
Here is a brief YouTube video preview of the final Action for Google Assistant, integrated with Elasticsearch, running on an Apple iPhone.
Architecture
If you recall from part one of this post, the high-level architecture of our search engine-enhanced Action for Google Assistant resembles the following. Most of the components are running on Google Cloud.
Source Code
All open-sourced code for this post can be found on GitHub in two repositories, one for the Spring Boot Service and one for the Action for Google Assistant. Code samples in this post are displayed as GitHub Gists, which may not display correctly on some mobile and social media browsers. Links to gists are also provided.
Development Process
In part two of this post, we will tie everything together by creating and integrating our Action for Google Assistant:
- Create the new Actions for Google Assistant project using the Actions on Google console;
- Develop the Action’s Intents and Entities using the Dialogflow console;
- Develop, deploy, and test the Cloud Function to GCP;
Let’s explore each step in more detail.
New ‘Actions on Google’ Project
With Elasticsearch running and the Spring Boot Service deployed to our GKE cluster, we can start building our Actions for Google Assistant. Using the Actions on Google web console, we first create a new Actions project.
The Directory Information tab is where we define metadata about the project. This information determines how it will look in the Actions directory and is required to publish your project. The Actions directory is where users discover published Actions on the web and mobile devices.
The Directory Information tab also includes sample invocations, which may be used to invoke our Actions.
Actions and Intents
Our project will contain a series of related Actions. According to Google, an Action is ‘an interaction you build for the Assistant that supports a specific intent and has a corresponding fulfillment that processes the intent.’ To build our Actions, we first want to create our Intents. To do so, we will want to switch from the Actions on Google console to the Dialogflow console. Actions on Google provides a link for switching to Dialogflow in the Actions tab.
We will build our Action’s Intents in Dialogflow. The term Intent, used by Dialogflow, is standard terminology across other voice-assistant platforms, such as Amazon’s Alexa and Microsoft’s Azure Bot Service and LUIS. In Dialogflow, will be building Intents — the Find Multiple Posts Intent, Find Post Intent, Find By ID Intent, and so forth.
Below, we see the Find Post Intent. The Find Post Intent is responsible for handling our user’s requests for a single post about a topic, for example, ‘Find a post about Docker.’ The Intent shown below contains a fair number, but indeed not an exhaustive list, of training phrases. These represent possible ways a user might express intent when invoking the Action.
Below, we see the Find Multiple Posts Intent. The Find Multiple Posts Intent is responsible for handling our user’s requests for a list of posts about a topic, for example, ‘I’m interested in Docker.’ Similar to the Find Post Intent above, the Find Multiple Posts Intent contains a list of training phrases.
Dialog Model Training
According to Google, the greater the number of natural language examples in the Training Phrases section of Intents, the better the classification accuracy. Every time a user interacts with our Action, the user’s utterances are logged. Using the Training tab in the Dialogflow console, we can train our model by reviewing and approving or correcting how the Action handled the user’s utterances.
Below we see the user’s utterances, part of an interaction with the Action. We have the option to review and approve the Intent that was called to handle the utterance, re-assign it, or delete it. This helps improve our accuracy of our dialog model.
Dialogflow Entities
Each of the highlighted words in the training phrases maps to the facts parameter, which maps to a collection of @topic Entities. Entities represent a list of intents the Action is trained to understand. According to Google, there are three types of entities: ‘system’ (defined by Dialogflow), ‘developer’ (defined by a developer), and ‘user’ (built for each individual end-user in every request) objects. We will be creating ‘developer’ type entities for our Action’s Intents.
Automated Expansion
We do not have to define all possible topics a user might search for, as an entity. By enabling the Allow Automated Expansion option, an Agent will recognize values that have not been explicitly listed in the entity list. Google describes Agents as NLU (Natural Language Understanding) modules.
Entity Synonyms
An entity may contain synonyms. Multiple synonyms are mapped to a single reference value. The reference value is the value passed to the Cloud Function by the Action. For example, take the reference value of ‘GCP.’ The user might ask Google about ‘GCP’. However, the user might also substitute the words ‘Google Cloud’ or ‘Google Cloud Platform.’ Using synonyms, if the user utters any of these three synonymous words or phrase in their intent, the reference value, ‘GCP’, is passed in the request.
But, what if the post contains the phrase, ‘Google Cloud Platform’ more frequently than, or instead of, ‘GCP’? If the acronym, ‘GCP’, is defined as the entity reference value, then it is the value passed to the function, even if you ask for ‘Google Cloud Platform’. In the use case of searching blog posts by topic, entity synonyms are not an effective search strategy.
Elasticsearch Synonyms
A better way to solve for synonyms is by using the synonyms feature of Elasticsearch. Take, for example, the topic of ‘Istio’, Istio is also considered a Service Mesh. If I ask for posts about ‘Service Mesh’, I would like to get back posts that contain the phrase ‘Service Mesh’, but also the word ‘Istio’. To accomplish this, you would define an association between ‘Istio’ and ‘Service Mesh’, as part of the Elasticsearch WordPress posts index.
Searches for ‘Istio’ against that index would return results that contain ‘Istio’ and/or contain ‘Service Mesh’; the reverse is also true. Having created and applied a custom synonyms filter to the index, we see how Elasticsearch responds to an analysis of the natural language style phrase, ‘What is a Service Mesh?’. As shown by the tokens output in Kibana’s Dev Tools Console, Elasticsearch understands that ‘service mesh’ is synonymous with ‘istio’.
If we query the same five fields as our Action, for the topic of ‘service mesh’, we get four hits for posts (indexed documents) that contain ‘service mesh’ and/or ‘istio’.
Actions on Google Integration
Another configuration item in Dialogflow that needs to be completed is the Dialogflow’s Actions on Google integration. This will integrate our Action with Google Assistant. Google currently provides more than fifteen different integrations, including Google Assistant, Slack, Facebook Messanger, Twitter, and Twilio, as shown below.
To configure the Google Assistant integration, choose the Welcome Intent as our Action’s Explicit Invocation intent. Then we designate our other Intents as Implicit Invocation intents. According to Google, this Google Assistant Integration allows our Action to reach users on every device where the Google Assistant is available.
Action Fulfillment
When a user’s intent is received, it is fulfilled by the Action. In the Dialogflow Fulfillment console, we see the Action has two fulfillment options, a Webhook or an inline-editable Cloud Function, edited inline. A Webhook allows us to pass information from a matched intent into a web service and get a result back from the service. Our Action’s Webhook will call our Cloud Function on GCP, using the Cloud Function’s URL endpoint (we’ll get this URL in the next section).
Google Cloud Functions
Our Cloud Function, called by our Action, is written in Node.js. Our function, index.js, is divided into four sections, which are: constants and environment variables, intent handlers, helper functions, and the function’s entry point. The helper functions are part of the Helper module, contained in the helper.js file.
Constants and Environment Variables
The section, in both index.js
and helper.js
, defines the global constants and environment variables used within the function. Values that reference environment variables, such as SEARCH_API_HOSTNAME
are defined in the .env.yaml
file. All environment variables in the .env.yaml
file will be set during the Cloud Function’s deployment, described later in this post. Environment variables were recently released, and are still considered beta functionality (gist).
// author: Gary A. Stafford | |
// site: https://programmaticponderings.com | |
// license: MIT License | |
'use strict'; | |
/* CONSTANTS AND GLOBAL VARIABLES */ | |
const Helper = require('./helper'); | |
let helper = new Helper(); | |
const { | |
dialogflow, | |
Button, | |
Suggestions, | |
BasicCard, | |
SimpleResponse, | |
List | |
} = require('actions-on-google'); | |
const functions = require('firebase-functions'); | |
const app = dialogflow({debug: true}); | |
app.middleware(conv => { | |
conv.hasScreen = | |
conv.surface.capabilities.has('actions.capability.SCREEN_OUTPUT'); | |
conv.hasAudioPlayback = | |
conv.surface.capabilities.has('actions.capability.AUDIO_OUTPUT'); | |
}); | |
const SUGGESTION_1 = 'tell me about Docker'; | |
const SUGGESTION_2 = 'help'; | |
const SUGGESTION_3 = 'cancel'; |
The npm module dependencies declared in this section are defined in the dependencies section of the package.json
file. Function dependencies include Actions on Google, Firebase Functions, Winston, and Request (gist).
{ | |
"name": "functionBlogSearchAction", | |
"description": "Programmatic Ponderings Search Action for Google Assistant", | |
"version": "1.0.0", | |
"private": true, | |
"license": "MIT License", | |
"author": "Gary A. Stafford", | |
"engines": { | |
"node": ">=8" | |
}, | |
"scripts": { | |
"deploy": "sh ./deploy-cloud-function.sh" | |
}, | |
"dependencies": { | |
"@google-cloud/logging-winston": "^0.9.0", | |
"actions-on-google": "^2.2.0", | |
"dialogflow": "^0.6.0", | |
"dialogflow-fulfillment": "^0.5.0", | |
"firebase-admin": "^6.0.0", | |
"firebase-functions": "^2.0.2", | |
"request": "^2.88.0", | |
"request-promise-native": "^1.0.5", | |
"winston": "2.4.4" | |
} | |
} |
Intent Handlers
The intent handlers in this section correspond to the intents in the Dialogflow console. Each handler responds with a SimpleResponse, BasicCard, and Suggestion Chip response types, or Simple Response, List, and Suggestion Chip response types. These response types were covered in part one of this post. (gist).
/* INTENT HANDLERS */ | |
app.intent('Welcome Intent', conv => { | |
const WELCOME_TEXT_SHORT = 'What topic are you interested in reading about?'; | |
const WELCOME_TEXT_LONG = `You can say things like: \n` + | |
` _'Find a post about GCP'_ \n` + | |
` _'I'd like to read about Kubernetes'_ \n` + | |
` _'I'm interested in Docker'_`; | |
conv.ask(new SimpleResponse({ | |
speech: WELCOME_TEXT_SHORT, | |
text: WELCOME_TEXT_SHORT, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
text: WELCOME_TEXT_LONG, | |
title: 'Programmatic Ponderings Search', | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Fallback Intent', conv => { | |
const FACTS_LIST = "Kubernetes, Docker, Cloud, DevOps, AWS, Spring, Azure, Messaging, and GCP"; | |
const HELP_TEXT_SHORT = 'Need a little help?'; | |
const HELP_TEXT_LONG = `Some popular topics include: ${FACTS_LIST}.`; | |
conv.ask(new SimpleResponse({ | |
speech: HELP_TEXT_LONG, | |
text: HELP_TEXT_SHORT, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
text: HELP_TEXT_LONG, | |
title: 'Programmatic Ponderings Search Help', | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Find Post Intent', async (conv, {topic}) => { | |
let postTopic = topic.toString(); | |
let posts = await helper.getPostsByTopic(postTopic, 1); | |
if (posts !== undefined && posts.length < 1) { | |
helper.topicNotFound(conv, postTopic); | |
return; | |
} | |
let post = posts[0]; | |
let formattedDate = helper.convertDate(post.post_date); | |
const POST_SPOKEN = `The top result for '${postTopic}' is the post, '${post.post_title}', published ${formattedDate}, with a relevance score of ${post._score.toFixed(2)}`; | |
const POST_TEXT = `Description: ${post.post_excerpt} \nPublished: ${formattedDate} \nScore: ${post._score.toFixed(2)}`; | |
conv.ask(new SimpleResponse({ | |
speech: POST_SPOKEN, | |
text: post.title, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
title: post.post_title, | |
text: POST_TEXT, | |
buttons: new Button({ | |
title: `Read Post`, | |
url: post.guid, | |
}), | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Find Multiple Posts Intent', async (conv, {topic}) => { | |
let postTopic = topic.toString(); | |
let postCount = 6; | |
let posts = await helper.getPostsByTopic(postTopic, postCount); | |
if (posts !== undefined && posts.length < 1) { | |
helper.topicNotFound(conv, postTopic); | |
return; | |
} | |
const POST_SPOKEN = `Here's a list of the top ${posts.length} posts about '${postTopic}'`; | |
conv.ask(new SimpleResponse({ | |
speech: POST_SPOKEN, | |
})); | |
let itemsArray = {}; | |
posts.forEach(function (post) { | |
itemsArray[post.ID] = { | |
title: `Post ID ${post.ID}`, | |
description: `${post.post_title.substring(0,80)}... \nScore: ${post._score.toFixed(2)}`, | |
}; | |
}); | |
if (conv.hasScreen) { | |
conv.ask(new List({ | |
title: 'Top Results', | |
items: itemsArray | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Find By ID Intent', async (conv, {topic}) => { | |
let postId = topic.toString(); | |
let post = await helper.getPostById(postId); | |
if (post === undefined) { | |
helper.postIdNotFound(conv, postId); | |
return; | |
} | |
let formattedDate = helper.convertDate(post.post_date); | |
const POST_SPOKEN = `Okay, I found that post`; | |
const POST_TEXT = `Description: ${post.post_excerpt} \nPublished: ${formattedDate}`; | |
conv.ask(new SimpleResponse({ | |
speech: POST_SPOKEN, | |
text: post.title, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
title: post.post_title, | |
text: POST_TEXT, | |
buttons: new Button({ | |
title: `Read Post`, | |
url: post.guid, | |
}), | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Option Intent', async (conv, params, option) => { | |
let postId = option.toString(); | |
let post = await helper.getPostById(postId); | |
if (post === undefined) { | |
helper.postIdNotFound(conv, postId); | |
return; | |
} | |
let formattedDate = helper.convertDate(post.post_date); | |
const POST_SPOKEN = `Sure, here's that post`; | |
const POST_TEXT = `Description: ${post.post_excerpt} \nPublished: ${formattedDate}`; | |
conv.ask(new SimpleResponse({ | |
speech: POST_SPOKEN, | |
text: post.title, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
title: post.post_title, | |
text: POST_TEXT, | |
buttons: new Button({ | |
title: `Read Post`, | |
url: post.guid, | |
}), | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); |
The Welcome Intent handler handles explicit invocations of our Action. The Fallback Intent handler handles both help requests, as well as cases when Dialogflow is unable to handle the user’s request.
As described above in the Dialogflow section, the Find Post Intent handler is responsible for handling our user’s requests for a single post about a topic. For example, ‘Find a post about Docker’. To fulfill the user request, the Find Post Intent handler, calls the Helper module’s getPostByTopic
function, passing the topic requested and specifying a result set size of one post with the highest relevance score higher than an arbitrary value of 1.0.
Similarly, the Find Multiple Posts Intent handler is responsible for handling our user’s requests for a list of posts about a topic; for example, ‘I’m interested in Docker’. To fulfill the user request, the Find Multiple Posts Intent handler, calls the Helper module’s getPostsByTopic
function, passing the topic requested and specifying a result set size of a maximum of six posts with the highest relevance scores greater than 1.0
The Find By ID Intent handler is responsible for handling our user’s requests for a specific, unique posts ID; for example, ‘Post ID 22141’. To fulfill the user request, the Find By ID Intent handler, calls the Helper module’s getPostById
function, passing the unique Post ID (gist).
/* INTENT HANDLERS */ | |
app.intent('Welcome Intent', conv => { | |
const WELCOME_TEXT_SHORT = 'What topic are you interested in reading about?'; | |
const WELCOME_TEXT_LONG = `You can say things like: \n` + | |
` _'Find a post about GCP'_ \n` + | |
` _'I'd like to read about Kubernetes'_ \n` + | |
` _'I'm interested in Docker'_`; | |
conv.ask(new SimpleResponse({ | |
speech: WELCOME_TEXT_SHORT, | |
text: WELCOME_TEXT_SHORT, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
text: WELCOME_TEXT_LONG, | |
title: 'Programmatic Ponderings Search', | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Fallback Intent', conv => { | |
const FACTS_LIST = "Kubernetes, Docker, Cloud, DevOps, AWS, Spring, Azure, Messaging, and GCP"; | |
const HELP_TEXT_SHORT = 'Need a little help?'; | |
const HELP_TEXT_LONG = `Some popular topics include: ${FACTS_LIST}.`; | |
conv.ask(new SimpleResponse({ | |
speech: HELP_TEXT_LONG, | |
text: HELP_TEXT_SHORT, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
text: HELP_TEXT_LONG, | |
title: 'Programmatic Ponderings Search Help', | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Find Post Intent', async (conv, {topic}) => { | |
let postTopic = topic.toString(); | |
let posts = await helper.getPostsByTopic(postTopic, 1); | |
if (posts !== undefined && posts.length < 1) { | |
helper.topicNotFound(conv, postTopic); | |
return; | |
} | |
let post = posts[0]; | |
let formattedDate = helper.convertDate(post.post_date); | |
const POST_SPOKEN = `The top result for '${postTopic}' is the post, '${post.post_title}', published ${formattedDate}, with a relevance score of ${post._score.toFixed(2)}`; | |
const POST_TEXT = `Description: ${post.post_excerpt} \nPublished: ${formattedDate} \nScore: ${post._score.toFixed(2)}`; | |
conv.ask(new SimpleResponse({ | |
speech: POST_SPOKEN, | |
text: post.title, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
title: post.post_title, | |
text: POST_TEXT, | |
buttons: new Button({ | |
title: `Read Post`, | |
url: post.guid, | |
}), | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Find Multiple Posts Intent', async (conv, {topic}) => { | |
let postTopic = topic.toString(); | |
let postCount = 6; | |
let posts = await helper.getPostsByTopic(postTopic, postCount); | |
if (posts !== undefined && posts.length < 1) { | |
helper.topicNotFound(conv, postTopic); | |
return; | |
} | |
const POST_SPOKEN = `Here's a list of the top ${posts.length} posts about '${postTopic}'`; | |
conv.ask(new SimpleResponse({ | |
speech: POST_SPOKEN, | |
})); | |
let itemsArray = {}; | |
posts.forEach(function (post) { | |
itemsArray[post.ID] = { | |
title: `Post ID ${post.ID}`, | |
description: `${post.post_title.substring(0,80)}... \nScore: ${post._score.toFixed(2)}`, | |
}; | |
}); | |
if (conv.hasScreen) { | |
conv.ask(new List({ | |
title: 'Top Results', | |
items: itemsArray | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Find By ID Intent', async (conv, {topic}) => { | |
let postId = topic.toString(); | |
let post = await helper.getPostById(postId); | |
if (post === undefined) { | |
helper.postIdNotFound(conv, postId); | |
return; | |
} | |
let formattedDate = helper.convertDate(post.post_date); | |
const POST_SPOKEN = `Okay, I found that post`; | |
const POST_TEXT = `Description: ${post.post_excerpt} \nPublished: ${formattedDate}`; | |
conv.ask(new SimpleResponse({ | |
speech: POST_SPOKEN, | |
text: post.title, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
title: post.post_title, | |
text: POST_TEXT, | |
buttons: new Button({ | |
title: `Read Post`, | |
url: post.guid, | |
}), | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Option Intent', async (conv, params, option) => { | |
let postId = option.toString(); | |
let post = await helper.getPostById(postId); | |
if (post === undefined) { | |
helper.postIdNotFound(conv, postId); | |
return; | |
} | |
let formattedDate = helper.convertDate(post.post_date); | |
const POST_SPOKEN = `Sure, here's that post`; | |
const POST_TEXT = `Description: ${post.post_excerpt} \nPublished: ${formattedDate}`; | |
conv.ask(new SimpleResponse({ | |
speech: POST_SPOKEN, | |
text: post.title, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
title: post.post_title, | |
text: POST_TEXT, | |
buttons: new Button({ | |
title: `Read Post`, | |
url: post.guid, | |
}), | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); |
Entry Point
The entry point creates a way to handle the communication with Dialogflow’s fulfillment API (gist).
/* ENTRY POINT */ | |
exports.functionBlogSearchAction = functions.https.onRequest(app); |
Helper Functions
The helper functions are part of the Helper module, contained in the helper.js file. In addition to typical utility functions like formatting dates, there are two functions, which interface with Elasticsearch, via our Spring Boot API, getPostsByTopic
and getPostById
. As described above, the intent handlers call one of these functions to obtain search results from Elasticsearch.
The getPostsByTopic
function handles both the Find Post Intent handler and Find Multiple Posts Intent handler, described above. The only difference in the two calls is the size of the response set, either one result or six results maximum (gist).
// author: Gary A. Stafford | |
// site: https://programmaticponderings.com | |
// license: MIT License | |
'use strict'; | |
/* CONSTANTS AND GLOBAL VARIABLES */ | |
const { | |
dialogflow, | |
BasicCard, | |
SimpleResponse, | |
} = require('actions-on-google'); | |
const app = dialogflow({debug: true}); | |
app.middleware(conv => { | |
conv.hasScreen = | |
conv.surface.capabilities.has('actions.capability.SCREEN_OUTPUT'); | |
conv.hasAudioPlayback = | |
conv.surface.capabilities.has('actions.capability.AUDIO_OUTPUT'); | |
}); | |
const SEARCH_API_HOSTNAME = process.env.SEARCH_API_HOSTNAME; | |
const SEARCH_API_PORT = process.env.SEARCH_API_PORT; | |
const SEARCH_API_ENDPOINT = process.env.SEARCH_API_ENDPOINT; | |
const rpn = require('request-promise-native'); | |
const winston = require('winston'); | |
const Logger = winston.Logger; | |
const Console = winston.transports.Console; | |
const {LoggingWinston} = require('@google-cloud/logging-winston'); | |
const loggingWinston = new LoggingWinston(); | |
const logger = new Logger({ | |
level: 'info', // log at 'info' and above | |
transports: [ | |
new Console(), | |
loggingWinston, | |
], | |
}); | |
/* HELPER FUNCTIONS */ | |
module.exports = class Helper { | |
/** | |
* Returns an collection of ElasticsearchPosts objects based on a topic | |
* @param postTopic topic to search for | |
* @param responseSize | |
* @returns {Promise<any>} | |
*/ | |
getPostsByTopic(postTopic, responseSize = 1) { | |
return new Promise((resolve, reject) => { | |
const SEARCH_API_RESOURCE = `dismax-search?value=${postTopic}&start=0&size=${responseSize}&minScore=1`; | |
const SEARCH_API_URL = `http://${SEARCH_API_HOSTNAME}:${SEARCH_API_PORT}/${SEARCH_API_ENDPOINT}/${SEARCH_API_RESOURCE}`; | |
logger.info(`getPostsByTopic API URL: ${SEARCH_API_URL}`); | |
let options = { | |
uri: SEARCH_API_URL, | |
json: true | |
}; | |
rpn(options) | |
.then(function (posts) { | |
posts = posts.ElasticsearchPosts; | |
logger.info(`getPostsByTopic Posts: ${JSON.stringify(posts)}`); | |
resolve(posts); | |
}) | |
.catch(function (err) { | |
logger.error(`Error: ${err}`); | |
reject(err) | |
}); | |
}); | |
} | |
// truncated for brevity | |
}; |
Both functions use the request and request-promise-native npm modules to call the Spring Boot service’s RESTful API over HTTP. However, instead of returning a callback, the request-promise-native module allows us to return a native ES6 Promise. By returning a promise, we can use async/await with our Intent handlers. Using async/await with Promises is a newer way of handling asynchronous operations in Node.js. The asynchronous programming model, using promises, is described in greater detail in my previous post, Building Serverless Actions for Google Assistant with Google Cloud Functions, Cloud Datastore, and Cloud Storage.
ThegetPostById
function handles both the Find By ID Intent handler and Option Intent handler, described above. This function is similar to the getPostsByTopic
function, calling a Spring Boot service’s RESTful API endpoint and passing the Post ID (gist).
// author: Gary A. Stafford | |
// site: https://programmaticponderings.com | |
// license: MIT License | |
// truncated for brevity | |
module.exports = class Helper { | |
/** | |
* Returns a single result based in the Post ID | |
* @param postId ID of the Post to search for | |
* @returns {Promise<any>} | |
*/ | |
getPostById(postId) { | |
return new Promise((resolve, reject) => { | |
const SEARCH_API_RESOURCE = `${postId}`; | |
const SEARCH_API_URL = `http://${SEARCH_API_HOSTNAME}:${SEARCH_API_PORT}/${SEARCH_API_ENDPOINT}/${SEARCH_API_RESOURCE}`; | |
logger.info(`getPostById API URL: ${SEARCH_API_URL}`); | |
let options = { | |
uri: SEARCH_API_URL, | |
json: true | |
}; | |
rpn(options) | |
.then(function (post) { | |
post = post.ElasticsearchPosts; | |
logger.info(`getPostById Post: ${JSON.stringify(post)}`); | |
resolve(post); | |
}) | |
.catch(function (err) { | |
logger.error(`Error: ${err}`); | |
reject(err) | |
}); | |
}); | |
} | |
// truncated for brevity | |
}; |
Cloud Function Deployment
To deploy the Cloud Function to GCP, use the gcloud
CLI with the beta version of the functions deploy command. According to Google, gcloud
is a part of the Google Cloud SDK. You must download and install the SDK on your system and initialize it before you can use gcloud
. Currently, Cloud Functions are only available in four regions. I have included a shell script, deploy-cloud-function.sh
, to make this step easier. It is called using the npm run deploy
function. (gist).
#!/usr/bin/env sh | |
# author: Gary A. Stafford | |
# site: https://programmaticponderings.com | |
# license: MIT License | |
set -ex | |
# Set constants | |
REGION="us-east1" | |
FUNCTION_NAME="<your_function_name>" | |
# Deploy the Google Cloud Function | |
gcloud beta functions deploy ${FUNCTION_NAME} \ | |
--runtime nodejs8 \ | |
--region ${REGION} \ | |
--trigger-http \ | |
--memory 256MB \ | |
--env-vars-file .env.yaml |
The creation or update of the Cloud Function can take up to two minutes. Note the output indicates the environment variables, contained in the .env.yaml
file, have been deployed. The URL endpoint of the function and the function’s entry point are also both output.
If you recall, the URL endpoint of the Cloud Function is required in the Dialogflow Fulfillment tab. The URL can be retrieved from the deployment output (shown above). The Cloud Function is now deployed and will be called by the Action when a user invokes the Action.
What is Deployed
The .gcloudignore
file is created the first time you deploy a new function. Using the the .gcloudignore
file, you limit the files deployed to GCP. For this post, of all the files in the project, only four files, index.js
, helper.js
, package.js
, and the PNG file used in the Action’s responses, need to be deployed. All other project files are ear-marked in the .gcloudignore
file to avoid being deployed.
Simulation Testing and Debugging
With our Action and all its dependencies deployed and configured, we can test the Action using the Simulation console on Actions on Google. According to Google, the Action Simulation console allows us to manually test our Action by simulating a variety of Google-enabled hardware devices and their settings.
Below, in the Simulation console, we see the successful display of our Programmatic Ponderings Search Action for Google Assistant containing the expected Simple Response, List, and Suggestion Chips response types, triggered by a user’s invocation of the Action.
The simulated response indicates that the Google Cloud Function was called, and it responded successfully. That also indicates the Dialogflow-based Action successfully communicated with the Cloud Function, the Cloud Function successfully communicated with the Spring Boot service instances running on Google Kubernetes Engine, and finally, the Spring Boot services successfully communicated with Elasticsearch running on Google Compute Engine.
If we had issues with the testing, the Action Simulation console also contains tabs containing the request and response objects sent to and from the Cloud Function, the audio response, a debug console, any errors, and access to the logs.
Stackdriver Logging
In the log output below, from our Cloud Function, we see our Cloud Function’s activities. These activities including information log entries, which we explicitly defined in our Cloud Function using the winston and @google-cloud/logging-winston npm modules. According to Google, the author of the module, Stackdriver Logging for Winston provides an easy to use, higher-level layer (transport) for working with Stackdriver Logging, compatible with Winston. Developing an effective logging strategy is essential to maintaining and troubleshooting your code in Development, as well as Production.
Conclusion
In this two-part post, we observed how the capabilities of a voice and text-based conversational interface, such as an Action for Google Assistant, may be enhanced through integration with a search and analytics engine, such as Elasticsearch. This post barely scraped the surface of what could be achieved with such an integration. Elasticsearch, as well as other leading Lucene-based search and analytics engines, such as Apache Solr, have tremendous capabilities, which are easily integrated to machine learning-based conversational interfaces, resulting in a more powerful and a more intuitive end-user experience.
All opinions expressed in this post are my own and not necessarily the views of my current or past employers, their clients, or Google.
Integrating Search Capabilities with Actions for Google Assistant, using GKE and Elasticsearch: Part 1
Posted by Gary A. Stafford in Cloud, GCP, Java Development, JavaScript, Serverless, Software Development on September 20, 2018
Voice and text-based conversational interfaces, such as chatbots, have recently seen tremendous growth in popularity. Much of this growth can be attributed to leading Cloud providers, such as Google, Amazon, and Microsoft, who now provide affordable, end-to-end development, machine learning-based training, and hosting platforms for conversational interfaces.
Cloud-based machine learning services greatly improve a conversational interface’s ability to interpret user intent with greater accuracy. However, the ability to return relevant responses to user inquiries, also requires interfaces have access to rich informational datastores, and the ability to quickly and efficiently query and analyze that data.
In this two-part post, we will enhance the capabilities of a voice and text-based conversational interface by integrating it with a search and analytics engine. By interfacing an Action for Google Assistant conversational interface with Elasticsearch, we will improve the Action’s ability to provide relevant results to the end-user. Instead of querying a traditional database for static responses to user intent, our Action will access a Near Realtime (NRT) Elasticsearch index of searchable documents. The Action will leverage Elasticsearch’s advanced search and analytics capabilities to optimize and shape user responses, based on their intent.
Action Preview
Here is a brief YouTube video preview of the final Action for Google Assistant, integrated with Elasticsearch, running on an Apple iPhone.
Google Technologies
The high-level architecture of our search engine-enhanced Action for Google Assistant will look as follows.
Here is a brief overview of the key technologies we will incorporate into our architecture.
Actions on Google
According to Google, Actions on Google is the platform for developers to extend the Google Assistant. Actions on Google is a web-based platform that provides a streamlined user-experience to create, manage, and deploy Actions. We will use the Actions on Google platform to develop our Action in this post.
Dialogflow
According to Google, Dialogflow is an enterprise-grade NLU platform that makes it easy for developers to design and integrate conversational user interfaces into mobile apps, web applications, devices, and bots. Dialogflow is powered by Google’s machine learning for Natural Language Processing (NLP).
Google Cloud Functions
Google Cloud Functions are part of Google’s event-driven, serverless compute platform, part of the Google Cloud Platform (GCP). Google Cloud Functions are analogous to Amazon’s AWS Lambda and Azure Functions. Features include automatic scaling, high availability, fault tolerance, no servers to provision, manage, patch or update, and a payment model based on the function’s execution time.
Google Kubernetes Engine
Kubernetes Engine is a managed, production-ready environment, available on GCP, for deploying containerized applications. According to Google, Kubernetes Engine is a reliable, efficient, and secure way to run Kubernetes clusters in the Cloud.
Elasticsearch
Elasticsearch is a leading, distributed, RESTful search and analytics engine. Elasticsearch is a product of Elastic, the company behind the Elastic Stack, which includes Elasticsearch, Kibana, Beats, Logstash, X-Pack, and Elastic Cloud. Elasticsearch provides a distributed, multitenant-capable, full-text search engine with an HTTP web interface and schema-free JSON documents. Elasticsearch is similar to Apache Solr in terms of features and functionality. Both Solr and Elasticsearch is based on Apache Lucene.
Other Technologies
In addition to the major technologies highlighted above, the project also relies on the following:
- Google Container Registry – As an alternative to Docker Hub, we will store the Spring Boot API service’s Docker Image in Google Container Registry, making deployment to GKE a breeze.
- Google Cloud Deployment Manager – Google Cloud Deployment Manager allows users to specify all the resources needed for application in a declarative format using YAML. The Elastic Stack will be deployed with Deployment Manager.
- Google Compute Engine – Google Compute Engine delivers scalable, high-performance virtual machines (VMs) running in Google’s data centers, on their worldwide fiber network.
- Google Stackdriver – Stackdriver aggregates metrics, logs, and events from our Cloud-based project infrastructure, for troubleshooting. We are also integrating Stackdriver Logging for Winston into our Cloud Function for fast application feedback.
- Google Cloud DNS – Hosts the primary project domain and subdomains for the search engine and API. Google Cloud DNS is a scalable, reliable and managed authoritative Domain Name System (DNS) service running on the same infrastructure as Google.
- Google VPC Network Firewall – Firewall rules provide fine-grain, secure access controls to our API and search engine. We will several firewall port openings to talk to the Elastic Stack.
- Spring Boot – Pivotal’s Spring Boot project makes it easy to create stand-alone, production-grade Spring-based Java applications, such as our Spring Boot service.
- Spring Data Elasticsearch – Pivotal Software’s Spring Data Elasticsearch project provides easy integration to Elasticsearch from our Java-based Spring Boot service.
Demonstration
To demonstrate an Action for Google Assistant with search engine integration, we need an index of content to search. In this post, we will build an informational Action, the Programmatic Ponderings Search Action, that responds to a user’s interests in certain technical topics, by returning post suggestions from the Programmatic Ponderings blog. For this demonstration, I have indexed the last two years worth of blog posts into Elasticsearch, using the ElasticPress WordPress plugin.
Source Code
All open-sourced code for this post can be found on GitHub in two repositories, one for the Spring Boot Service and one for the Action for Google Assistant. Code samples in this post are displayed as GitHub Gists, which may not display correctly on some mobile and social media browsers. Links to gists are also provided.
Development Process
This post will focus on the development and integration of the Action for Google Assistant with Elasticsearch, via a Google Cloud Function, Kubernetes Engine, and the Spring Boot API service. The post is not intended to be a general how-to on developing for Actions for Google Assistant, Google Cloud Platform, Elasticsearch, or WordPress.
Building and integrating the Action will involve the following steps:
- Design the Action’s conversation model;
- Provision the Elastic Stack on Google Compute Engine using Deployment Manager;
- Create an Elasticsearch index of blog posts;
- Provision the Kubernetes cluster on GCP with GKE;
- Develop and deploy the Spring Boot API service to Kubernetes;
Covered in Part Two of the Post:
- Create a new Actions project using the Actions on Google;
- Develop the Action’s Intents using the Dialogflow;
- Develop, deploy, and test the Cloud Function to GCP;
Let’s explore each step in more detail.
Conversational Model
The conversational model design of the Programmatic Ponderings Search Action for Google Assistant will have the option to invoke the Action in two ways, with or without intent. Below on the left, we see an example of an invocation of the Action – ‘Talk to Programmatic Ponderings’. Google Assistant then responds to the user for more information (intent) – ‘What topic are you interested in reading about?’.
Below on the left, we see an invocation of the Action, which includes the intent – ‘Ask Programmatic Ponderings to find a post about Kubernetes’. Google Assistant will respond directly, both verbally and visually with the most relevant post.
When a user requests a single result, for example, ‘Find a post about Docker’, Google Assistant will include Simple Response, Basic Card, and Suggestion Chip response types for devices with a display. This is shown in the center, above. The user may continue to ask for additional facts or choose to cancel the Action at any time.
When a user requests multiple results, for example, ‘I’m interested in Docker’, Google Assistant will include Simple Response, List, and Suggestion Chip response types for devices with a display. An example of a List Response is shown in the center of the previous set of screengrabs, above. The user will receive up to six results in the list, with a relevance score of 1.0 or greater. The user may choose to click on any of the post results in the list, which will initiate a new search using the post’s unique ID, as shown on the right, in the first set of screengrabs, above.
The conversational model also understands a request for help and to cancel the interaction.
GCP Account and Project
The following steps assume you have an existing GCP account and you have created a project on GCP to house the Cloud Function, GKE Cluster, and Elastic Stack on Google Compute Engine. The post also assumes that you have the latest Google Cloud SDK installed on your development machine, and have authenticated your identity from the command line (gist).
# Authenticate with the Google Cloud SDK | |
export PROJECT_ID="<your_project_id>" | |
gcloud beta auth login | |
gcloud config set project ${PROJECT_ID} | |
# Update components or new runtime nodejs8 may be unknown | |
gcloud components update |
Elasticsearch on GCP
There are a number of options available to host Elasticsearch. Elastic, the company behind Elasticsearch, offers the Elasticsearch Service, a fully managed, scalable, and reliable service on AWS and GCP. AWS also offers their own managed Elasticsearch Service. I found some limitations with AWS’ Elasticsearch Service, which made integration with Spring Data Elasticsearch difficult. According to AWS, the service supports HTTP but does not support TCP transport.
For this post, we will stand up the Elastic Stack on GCP using an offering from the Google Cloud Platform Marketplace. A well-known provider of packaged applications for multiple Cloud platforms, Bitnami, offers the ELK Stack (the previous name for the Elastic Stack), running on Google Compute Engine.
GCP Marketplace Solutions are deployed using the Google Cloud Deployment Manager. The Bitnami ELK solution is a complete stack with all the necessary software and software-defined Cloud infrastructure to securely run Elasticsearch. You select the instance’s zone(s), machine type, boot disk size, and security and networking configurations. Using that configuration, the Deployment Manager will deploy the solution and provide you with information and credentials for accessing the Elastic Stack. For this demo, we will configure a minimally-sized, single VM instance to run the Elastic Stack.
Below we see the Bitnami ELK stack’s components being created on GCP, by the Deployment Manager.
Indexed Content
With the Elastic Stack fully provisioned, I then configured WordPress to index the last two years of the Programmatic Pondering blog posts to Elasticsearch on GCP. If you want to follow along with this post and content to index, there is plenty of open source and public domain indexable content available on the Internet – books, movie lists, government and weather data, online catalogs of products, and so forth. Anything in a document database is directly indexable in Elasticsearch. Elastic even provides a set of index samples, available on their GitHub site.
Firewall Ports for Elasticseach
The Deployment Manager opens up firewall ports 80 and 443. To index the WordPress posts, I also had to open port 9200. According to Elastic, Elasticsearch uses port 9200 for communicating with their RESTful API with JSON over HTTP. For security, I locked down this firewall opening to my WordPress server’s address as the source. (gist).
SOURCE_IP=<wordpress_ip_address> | |
PORT=9200 | |
gcloud compute \ | |
--project=wp-search-bot \ | |
firewall-rules create elk-1-tcp-${PORT} \ | |
--description=elk-1-tcp-${PORT} \ | |
--direction=INGRESS \ | |
--priority=1000 \ | |
--network=default \ | |
--action=ALLOW \ | |
--rules=tcp:${PORT} \ | |
--source-ranges=${SOURCE_IP} \ | |
--target-tags=elk-1-tcp-${PORT} |
The two existing firewall rules for port opening 80 and 443 should also be locked down to your own IP address as the source. Common Elasticsearch ports are constantly scanned by Hackers, who will quickly hijack your Elasticsearch contents and hold them for ransom, in addition to deleting your indexes. Similar tactics are used on well-known and unprotected ports for many platforms, including Redis, MySQL, PostgreSQL, MongoDB, and Microsoft SQL Server.
Kibana
Once the posts are indexed, the best way to view the resulting Elasticsearch documents is through Kibana, which is included as part of the Bitnami solution. Below we see approximately thirty posts, spread out across two years.
Each Elasticsearch document, representing an indexed WordPress blog post, contains over 125 fields of information. Fields include a unique post ID, post title, content, publish date, excerpt, author, URL, and so forth. All these fields are exposed through Elasticsearch’s API, and as we will see, will be available to our Spring Boot service to query.
Spring Boot Service
To ensure decoupling between the Action for Google Assistant and Elasticsearch, we will expose a RESTful search API, written in Java using Spring Boot and Spring Data Elasticsearch. The API will expose a tailored set of flexible endpoints to the Action. Google’s machine learning services will ensure our conversational model is trained to understand user intent. The API’s query algorithm and Elasticsearch’s rich Lucene-based search features will ensure the most relevant results are returned. We will host the Spring Boot service on Google Kubernetes Engine (GKE).
Will use a Spring Rest Controller to expose our RESTful web service’s resources to our Action’s Cloud Function. The current Spring Boot service contains five /elastic
resource endpoints exposed by the ElasticsearchPostController
class . Of those five, two endpoints will be called by our Action in this demo, the /{id}
and the /dismax-search
endpoints. The endpoints can be seen using the Swagger UI. Our Spring Boot service implements SpringFox, which has the option to expose the Swagger interactive API UI.
The /{id}
endpoint accepts a unique post ID as a path variable in the API call and returns a single ElasticsearchPost object wrapped in a Map object, and serialized to a JSON payload (gist).
@RequestMapping(value = "/{id}") | |
@ApiOperation(value = "Returns a post by id") | |
public Map<String, Optional<ElasticsearchPost>> findById(@PathVariable("id") long id) { | |
Optional<ElasticsearchPost> elasticsearchPost = elasticsearchPostRepository.findById(id); | |
Map<String, Optional<ElasticsearchPost>> elasticsearchPostMap = new HashMap<>(); | |
elasticsearchPostMap.put("ElasticsearchPosts", elasticsearchPost); | |
return elasticsearchPostMap; | |
} |
Below we see an example response from the Spring Boot service to an API call to the /{id}
endpoint, for post ID 22141. Since we are returning a single post, based on ID, the relevance score will always be 0.0 (gist).
# http http://api.chatbotzlabs.com/blog/api/v1/elastic/22141 | |
HTTP/1.1 200 | |
Content-Type: application/json;charset=UTF-8 | |
Date: Mon, 17 Sep 2018 23:15:01 GMT | |
Transfer-Encoding: chunked | |
{ | |
"ElasticsearchPosts": { | |
"ID": 22141, | |
"_score": 0.0, | |
"guid": "https://programmaticponderings.com/?p=22141", | |
"post_date": "2018-04-13 12:45:19", | |
"post_excerpt": "Learn to manage distributed applications, spanning multiple Kubernetes environments, using Istio on GKE.", | |
"post_title": "Managing Applications Across Multiple Kubernetes Environments with Istio: Part 1" | |
} | |
} |
This controller’s /{id}
endpoint relies on a method exposed by the ElasticsearchPostRepository
interface. The ElasticsearchPostRepository
is a Spring Data Repository , which extends ElasticsearchRepository. The repository exposes the findById()
method, which returns a single instance of the type, ElasticsearchPost
, from Elasticsearch (gist).
package com.example.elasticsearch.repository; | |
import com.example.elasticsearch.model.ElasticsearchPost; | |
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; | |
public interface ElasticsearchPostRepository extends ElasticsearchRepository<ElasticsearchPost, Long> { | |
} |
The ElasticsearchPost
class is annotated as an Elasticsearch Document
, similar to other Spring Data Document
annotations, such as Spring Data MongoDB. The ElasticsearchPost
class is instantiated to hold deserialized JSON documents stored in ElasticSeach stores indexed data (gist).
package com.example.elasticsearch.model; | |
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | |
import com.fasterxml.jackson.annotation.JsonProperty; | |
import org.springframework.data.annotation.Id; | |
import org.springframework.data.elasticsearch.annotations.Document; | |
import java.io.Serializable; | |
@JsonIgnoreProperties(ignoreUnknown = true) | |
@Document(indexName = "<elasticsearch_index_name>", type = "post") | |
public class ElasticsearchPost implements Serializable { | |
@Id | |
@JsonProperty("ID") | |
private long id; | |
@JsonProperty("_score") | |
private float score; | |
@JsonProperty("post_title") | |
private String title; | |
@JsonProperty("post_date") | |
private String publishDate; | |
@JsonProperty("post_excerpt") | |
private String excerpt; | |
@JsonProperty("guid") | |
private String url; | |
// Setters removed for brevity... | |
} |
Dis Max Query
The second API endpoint called by our Action is the /dismax-search
endpoint. We use this endpoint to search for a particular post topic, such as ’Docker’. This type of search, as opposed to the Spring Data Repository method used by the /{id}
endpoint, requires the use of an ElasticsearchTemplate. The ElasticsearchTemplate
allows us to form more complex Elasticsearch queries than is possible using an ElasticsearchRepository
class. Below, the /dismax-search
endpoint accepts four input request parameters in the API call, which are the topic to search for, the starting point and size of the response to return, and the minimum relevance score (gist).
@RequestMapping(value = "/dismax-search") | |
@ApiOperation(value = "Performs dismax search and returns a list of posts containing the value input") | |
public Map<String, List<ElasticsearchPost>> dismaxSearch(@RequestParam("value") String value, | |
@RequestParam("start") int start, | |
@RequestParam("size") int size, | |
@RequestParam("minScore") float minScore) { | |
List<ElasticsearchPost> elasticsearchPosts = elasticsearchService.dismaxSearch(value, start, size, minScore); | |
Map<String, List<ElasticsearchPost>> elasticsearchPostMap = new HashMap<>(); | |
elasticsearchPostMap.put("ElasticsearchPosts", elasticsearchPosts); | |
return elasticsearchPostMap; | |
} |
The logic to create and execute the ElasticsearchTemplate is
handled by the ElasticsearchService
class. The ElasticsearchPostController
calls the ElasticsearchService
. The ElasticsearchService
handles querying Elasticsearch and returning a list of ElasticsearchPost
objects to the ElasticsearchPostController
. The dismaxSearch
method, called by the /dismax-search
endpoint’s method constructs the ElasticsearchTemplate instance, used to build the request to Elasticsearch’s RESTful API (gist).
public List<ElasticsearchPost> dismaxSearch(String value, int start, int size, float minScore) { | |
QueryBuilder queryBuilder = getQueryBuilder(value); | |
Client client = elasticsearchTemplate.getClient(); | |
SearchResponse response = client.prepareSearch() | |
.setQuery(queryBuilder) | |
.setSize(size) | |
.setFrom(start) | |
.setMinScore(minScore) | |
.addSort("_score", SortOrder.DESC) | |
.setExplain(true) | |
.execute() | |
.actionGet(); | |
List<SearchHit> searchHits = Arrays.asList(response.getHits().getHits()); | |
ObjectMapper mapper = new ObjectMapper(); | |
List<ElasticsearchPost> elasticsearchPosts = new ArrayList<>(); | |
searchHits.forEach(hit -> { | |
try { | |
elasticsearchPosts.add(mapper.readValue(hit.getSourceAsString(), ElasticsearchPost.class)); | |
elasticsearchPosts.get(elasticsearchPosts.size() - 1).setScore(hit.getScore()); | |
} catch (IOException e) { | |
e.printStackTrace(); | |
} | |
}); | |
return elasticsearchPosts; | |
} |
To obtain the most relevant search results, we will use Elasticsearch’s Dis Max Query combined with the Match Phrase Query. Elastic describes the Dis Max Query as:
‘a query that generates the union of documents produced by its subqueries, and that scores each document with the maximum score for that document as produced by any subquery, plus a tie breaking increment for any additional matching subqueries.’
In short, the Dis Max Query allows us to query and weight (boost importance) multiple indexed fields, across all documents. The Match Phrase Query analyzes the text (our topic) and creates a phrase query out of the analyzed text.
After some experimentation, I found the valid search results were returned by applying greater weighting (boost) to the post’s title and excerpt, followed by the post’s tags and categories, and finally, the actual text of the post. I also limited results to a minimum score of 1.0. Just because a word or phrase is repeated in a post, doesn’t mean it is indicative of the post’s subject matter. Setting a minimum score attempts to help ensure the requested topic is featured more prominently in the resulting post or posts. Increasing the minimum score will decrease the number of search results, but theoretically, increase their relevance (gist).
private QueryBuilder getQueryBuilder(String value) { | |
value = value.toLowerCase(); | |
return QueryBuilders.disMaxQuery() | |
.add(matchPhraseQuery("post_title", value).boost(3)) | |
.add(matchPhraseQuery("post_excerpt", value).boost(3)) | |
.add(matchPhraseQuery("terms.post_tag.name", value).boost(2)) | |
.add(matchPhraseQuery("terms.category.name", value).boost(2)) | |
.add(matchPhraseQuery("post_content", value).boost(1)); | |
} |
Below we see the results of a /dismax-search
API call to our service, querying for posts about the topic, ’Istio’, with a minimum score of 2.0. The search resulted in a serialized JSON payload containing three ElasticsearchPost
objects (gist).
http http://api.chatbotzlabs.com/blog/api/v1/elastic/dismax-search?minScore=2&size=3&start=0&value=Istio | |
HTTP/1.1 200 | |
Content-Type: application/json;charset=UTF-8 | |
Date: Tue, 18 Sep 2018 03:50:35 GMT | |
Transfer-Encoding: chunked | |
{ | |
"ElasticsearchPosts": [ | |
{ | |
"ID": 21867, | |
"_score": 5.91989, | |
"guid": "https://programmaticponderings.com/?p=21867", | |
"post_date": "2017-12-22 16:04:17", | |
"post_excerpt": "Learn to deploy and configure Istio on Google Kubernetes Engine (GKE).", | |
"post_title": "Deploying and Configuring Istio on Google Kubernetes Engine (GKE)" | |
}, | |
{ | |
"ID": 22313, | |
"_score": 3.6616292, | |
"guid": "https://programmaticponderings.com/?p=22313", | |
"post_date": "2018-04-17 07:01:38", | |
"post_excerpt": "Learn to manage distributed applications, spanning multiple Kubernetes environments, using Istio on GKE.", | |
"post_title": "Managing Applications Across Multiple Kubernetes Environments with Istio: Part 2" | |
}, | |
{ | |
"ID": 22141, | |
"_score": 3.6616292, | |
"guid": "https://programmaticponderings.com/?p=22141", | |
"post_date": "2018-04-13 12:45:19", | |
"post_excerpt": "Learn to manage distributed applications, spanning multiple Kubernetes environments, using Istio on GKE.", | |
"post_title": "Managing Applications Across Multiple Kubernetes Environments with Istio: Part 1" | |
} | |
] | |
} |
Understanding Relevance Scoring
When returning search results, such as in the example above, the top result is the one with the highest score. The highest score should denote the most relevant result to the search query. According to Elastic, in their document titled, The Theory Behind Relevance Scoring, scoring is explained this way:
‘Lucene (and thus Elasticsearch) uses the Boolean model to find matching documents, and a formula called the practical scoring function to calculate relevance. This formula borrows concepts from term frequency/inverse document frequency and the vector space model but adds more-modern features like a coordination factor, field length normalization, and term or query clause boosting.’
In order to better understand this technical explanation of relevance scoring, it is much easy to see it applied to our example. Note the first search result above, Post ID 21867, has the highest score, 5.91989. Knowing that we are searching five fields (title, excerpt, tags, categories, and content), and boosting certain fields more than others, how was this score determined? Conveniently, Spring Data Elasticsearch’s SearchRequestBuilder
class exposed the setExplain
method. We can see this on line 12 of the dimaxQuery
method, shown above. By passing a boolean value of true
to the setExplain
method, we are able to see the detailed scoring algorithms used by Elasticsearch for the top result, shown above (gist).
5.9198895 = max of: | |
5.8995476 = weight(post_title:istio in 3) [PerFieldSimilarity], result of: | |
5.8995476 = score(doc=3,freq=1.0 = termFreq=1.0), product of: | |
3.0 = boost | |
1.6739764 = idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from: | |
1.0 = docFreq | |
7.0 = docCount | |
1.1747572 = tfNorm, computed as (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength)) from: | |
1.0 = termFreq=1.0 | |
1.2 = parameter k1 | |
0.75 = parameter b | |
11.0 = avgFieldLength | |
7.0 = fieldLength | |
5.9198895 = weight(post_excerpt:istio in 3) [PerFieldSimilarity], result of: | |
5.9198895 = score(doc=3,freq=1.0 = termFreq=1.0), product of: | |
3.0 = boost | |
1.6739764 = idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from: | |
1.0 = docFreq | |
7.0 = docCount | |
1.1788079 = tfNorm, computed as (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength)) from: | |
1.0 = termFreq=1.0 | |
1.2 = parameter k1 | |
0.75 = parameter b | |
12.714286 = avgFieldLength | |
8.0 = fieldLength | |
3.3479528 = weight(terms.post_tag.name:istio in 3) [PerFieldSimilarity], result of: | |
3.3479528 = score(doc=3,freq=1.0 = termFreq=1.0), product of: | |
2.0 = boost | |
1.6739764 = idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from: | |
1.0 = docFreq | |
7.0 = docCount | |
1.0 = tfNorm, computed as (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength)) from: | |
1.0 = termFreq=1.0 | |
1.2 = parameter k1 | |
0.75 = parameter b | |
16.0 = avgFieldLength | |
16.0 = fieldLength | |
2.52272 = weight(post_content:istio in 3) [PerFieldSimilarity], result of: | |
2.52272 = score(doc=3,freq=100.0 = termFreq=100.0), product of: | |
1.1631508 = idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from: | |
2.0 = docFreq | |
7.0 = docCount | |
2.1688676 = tfNorm, computed as (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength)) from: | |
100.0 = termFreq=100.0 | |
1.2 = parameter k1 | |
0.75 = parameter b | |
2251.1428 = avgFieldLength | |
2840.0 = fieldLength |
What this detail shows us is that of the five fields searched, the term ‘Istio’ was located in four of the five fields (all except ‘categories’). Using the practical scoring function described by Elasticsearch, and taking into account our boost values, we see that the post’s ‘excerpt’ field achieved the highest score of 5.9198895 (score of 1.6739764 * boost of 3.0).
Being able to view the scoring explanation helps us tune our search results. For example, according to the details, the term ‘Istio’ appeared 100 times (termFreq=100.0
) in the main body of the post (the ‘content’ field). We might ask ourselves if we are giving enough relevance to the content as opposed to other fields. We might choose to increase the boost or decrease other fields with respect to the ‘content’ field, to produce higher quality search results.
Google Kubernetes Engine
With the Elastic Stack running on Google Compute Engine, and the Spring Boot API service built, we can now provision a Kubernetes cluster to run our Spring Boot service. The service will sit between our Action’s Cloud Function and Elasticsearch. We will use Google Kubernetes Engine (GKE) to manage our Kubernete cluster on GCP. A GKE cluster is a managed group of uniform VM instances for running Kubernetes. The VMs are managed by Google Compute Engine. Google Compute Engine delivers virtual machines running in Google’s data centers, on their worldwide fiber network.
A GKE cluster can be provisioned using GCP’s Cloud Console or using the Cloud SDK, Google’s command-line interface for Google Cloud Platform products and services. I prefer using the CLI, which helps enable DevOps automation through tools like Jenkins and Travis CI (gist).
GCP_PROJECT="wp-search-bot" | |
GKE_CLUSTER="wp-search-cluster" | |
GCP_ZONE="us-east1-b" | |
NODE_COUNT="1" | |
INSTANCE_TYPE="n1-standard-1" | |
GKE_VERSION="1.10.7-gke.1" | |
gcloud beta container \ | |
--project ${GCP_PROJECT} clusters create ${GKE_CLUSTER} \ | |
--zone ${GCP_ZONE} \ | |
--username "admin" \ | |
--cluster-version ${GKE_VERION} \ | |
--machine-type ${INSTANCE_TYPE} --image-type "COS" \ | |
--disk-type "pd-standard" --disk-size "100" \ | |
--scopes "https://www.googleapis.com/auth/devstorage.read_only","https://www.googleapis.com/auth/logging.write","https://www.googleapis.com/auth/monitoring","https://www.googleapis.com/auth/servicecontrol","https://www.googleapis.com/auth/service.management.readonly","https://www.googleapis.com/auth/trace.append" \ | |
--num-nodes ${NODE_COUNT} \ | |
--enable-cloud-logging --enable-cloud-monitoring \ | |
--network "projects/wp-search-bot/global/networks/default" \ | |
--subnetwork "projects/wp-search-bot/regions/us-east1/subnetworks/default" \ | |
--additional-zones "us-east1-b","us-east1-c","us-east1-d" \ | |
--addons HorizontalPodAutoscaling,HttpLoadBalancing \ | |
--no-enable-autoupgrade --enable-autorepair |
Below is the command I used to provision a minimally sized three-node GKE cluster, replete with the latest available version of Kubernetes. Although a one-node cluster is sufficient for early-stage development, testing should be done on a multi-node cluster to ensure the service will operate properly with multiple instances running behind a load-balancer (gist).
GCP_PROJECT="wp-search-bot" | |
GKE_CLUSTER="wp-search-cluster" | |
GCP_ZONE="us-east1-b" | |
NODE_COUNT="1" | |
INSTANCE_TYPE="n1-standard-1" | |
GKE_VERSION="1.10.7-gke.1" | |
gcloud beta container \ | |
--project ${GCP_PROJECT} clusters create ${GKE_CLUSTER} \ | |
--zone ${GCP_ZONE} \ | |
--username "admin" \ | |
--cluster-version ${GKE_VERION} \ | |
--machine-type ${INSTANCE_TYPE} --image-type "COS" \ | |
--disk-type "pd-standard" --disk-size "100" \ | |
--scopes "https://www.googleapis.com/auth/devstorage.read_only","https://www.googleapis.com/auth/logging.write","https://www.googleapis.com/auth/monitoring","https://www.googleapis.com/auth/servicecontrol","https://www.googleapis.com/auth/service.management.readonly","https://www.googleapis.com/auth/trace.append" \ | |
--num-nodes ${NODE_COUNT} \ | |
--enable-cloud-logging --enable-cloud-monitoring \ | |
--network "projects/wp-search-bot/global/networks/default" \ | |
--subnetwork "projects/wp-search-bot/regions/us-east1/subnetworks/default" \ | |
--additional-zones "us-east1-b","us-east1-c","us-east1-d" \ | |
--addons HorizontalPodAutoscaling,HttpLoadBalancing \ | |
--no-enable-autoupgrade --enable-autorepair |
Below, we see the three n1-standard-1
instance type worker nodes, one in each of three different specific geographical locations, referred to as zones. The three zones are in the us-east1
region. Multiple instances spread across multiple zones provide single-region high-availability for our Spring Boot service. With GKE, the Master Node is fully managed by Google.
Building Service Image
In order to deploy our Spring Boot service, we must first build a Docker Image and make that image available to our Kubernetes cluster. For lowest latency, I’ve chosen to build and publish the image to Google Container Registry, in addition to Docker Hub. The Spring Boot service’s Docker image is built on the latest Debian-based OpenJDK 10 Slim base image, available on Docker Hub. The Spring Boot JAR file is copied into the image (gist).
FROM openjdk:10.0.2-13-jdk-slim | |
LABEL maintainer="Gary A. Stafford <garystafford@rochester.rr.com>" | |
ENV REFRESHED_AT 2018-09-08 | |
EXPOSE 8080 | |
WORKDIR /tmp | |
COPY /build/libs/*.jar app.jar | |
CMD ["java", "-jar", "-Djava.security.egd=file:/dev/./urandom", "-Dspring.profiles.active=gcp", "app.jar"] |
To automate the build and publish processes with tools such as Jenkins or Travis CI, we will use a simple shell script. The script builds the Spring Boot service using Gradle, then builds the Docker Image containing the Spring Boot JAR file, tags and publishes the Docker image to the image repository, and finally, redeploys the Spring Boot service container to GKE using kubectl (gist).
#!/usr/bin/env sh | |
# author: Gary A. Stafford | |
# site: https://programmaticponderings.com | |
# license: MIT License | |
IMAGE_REPOSITORY=<your_image_repo> | |
IMAGE_NAME=<your_image_name> | |
GCP_PROJECT=<your_project> | |
TAG=<your_image_tag> | |
# Build Spring Boot app | |
./gradlew clean build | |
# Build Docker file | |
docker build -f Docker/Dockerfile --no-cache -t ${IMAGE_REPOSITORY}/${IMAGE_NAME}:${TAG} . | |
# Push image to Docker Hub | |
docker push ${IMAGE_REPOSITORY}/${IMAGE_NAME}:${TAG} | |
# Push image to GCP Container Registry (GCR) | |
docker tag ${IMAGE_REPOSITORY}/${IMAGE_NAME}:${TAG} gcr.io/${GCP_PROJECT}/${IMAGE_NAME}:${TAG} | |
docker push gcr.io/${GCP_PROJECT}/${IMAGE_NAME}:${TAG} | |
# Re-deploy Workload (containerized app) to GKE | |
kubectl replace --force -f gke/${IMAGE_NAME}.yaml |
Below we see the latest version of our Spring Boot Docker image published to the Google Cloud Registry.
Deploying the Service
To deploy the Spring Boot service’s container to GKE, we will use a Kubernetes Deployment Controller. The Deployment Controller manages the Pods and ReplicaSets. As a deployment alternative, you could choose to use CoreOS’ Operator Framework to create an Operator or use Helm to create a Helm Chart. Along with the Deployment Controller, there is a ConfigMap and a Horizontal Pod Autoscaler. The ConfigMap contains environment variables that will be available to the Spring Boot service instances running in the Kubernetes Pods. Variables include the host and port of the Elasticsearch cluster on GCP and the name of the Elasticsearch index created by WordPress. These values will override any configuration values set in the service’s application.yml
Java properties file.
The Deployment Controller creates a ReplicaSet with three Pods, running the Spring Boot service, one on each worker node (gist).
--- | |
apiVersion: "v1" | |
kind: "ConfigMap" | |
metadata: | |
name: "wp-es-demo-config" | |
namespace: "dev" | |
labels: | |
app: "wp-es-demo" | |
data: | |
cluster_nodes: "<your_elasticsearch_instance_tcp_host_and_port>" | |
cluser_name: "elasticsearch" | |
--- | |
apiVersion: "extensions/v1beta1" | |
kind: "Deployment" | |
metadata: | |
name: "wp-es-demo" | |
namespace: "dev" | |
labels: | |
app: "wp-es-demo" | |
spec: | |
replicas: 3 | |
selector: | |
matchLabels: | |
app: "wp-es-demo" | |
template: | |
metadata: | |
labels: | |
app: "wp-es-demo" | |
spec: | |
containers: | |
- name: "wp-es-demo" | |
image: "gcr.io/wp-search-bot/wp-es-demo" | |
imagePullPolicy: Always | |
env: | |
- name: "SPRING_DATA_ELASTICSEARCH_CLUSTER-NODES" | |
valueFrom: | |
configMapKeyRef: | |
key: "cluster_nodes" | |
name: "wp-es-demo-config" | |
- name: "SPRING_DATA_ELASTICSEARCH_CLUSTER-NAME" | |
valueFrom: | |
configMapKeyRef: | |
key: "cluser_name" | |
name: "wp-es-demo-config" | |
--- | |
apiVersion: "autoscaling/v1" | |
kind: "HorizontalPodAutoscaler" | |
metadata: | |
name: "wp-es-demo-hpa" | |
namespace: "dev" | |
labels: | |
app: "wp-es-demo" | |
spec: | |
scaleTargetRef: | |
kind: "Deployment" | |
name: "wp-es-demo" | |
apiVersion: "apps/v1beta1" | |
minReplicas: 1 | |
maxReplicas: 3 | |
targetCPUUtilizationPercentage: 80 |
To properly load-balance the three Spring Boot service Pods, we will also deploy a Kubernetes Service of the Kubernetes ServiceType, LoadBalancer. According to Kubernetes, a Kubernetes Service is an abstraction which defines a logical set of Pods and a policy by which to access them (gist).
--- | |
apiVersion: "v1" | |
kind: "Service" | |
metadata: | |
name: "wp-es-demo-service" | |
namespace: "dev" | |
labels: | |
app: "wp-es-demo" | |
spec: | |
ports: | |
- protocol: "TCP" | |
port: 80 | |
targetPort: 8080 | |
selector: | |
app: "wp-es-demo" | |
type: "LoadBalancer" | |
loadBalancerIP: "" |
Below, we see three instances of the Spring Boot service deployed to the GKE cluster on GCP. Each Pod, containing an instance of the Spring Boot service, is in a load-balanced pool, behind our service load balancer, and exposed on port 80.
Testing the API
We can test our API and ensure it is talking to Elasticsearch, and returning expected results using the Swagger UI, shown previously, or tools like Postman, shown below.
Communication Between GKE and Elasticsearch
Similar to port 9200, which needed to be opened for indexing content over HTTP, we also need to open firewall port 9300 between the Spring Boot service on GKE and Elasticsearch. According to Elastic, Elasticsearch Java clients talk to the Elasticsearch cluster over port 9300, using the native Elasticsearch transport protocol (TCP).
Again, locking this port down to the GKE cluster as the source is critical for security (gist).
SOURCE_IP=<gke_cluster_public_ip_address> | |
PORT=9300 | |
gcloud compute \ | |
--project=wp-search-bot \ | |
firewall-rules create elk-1-tcp-${PORT} \ | |
--description=elk-1-tcp-${PORT} \ | |
--direction=INGRESS \ | |
--priority=1000 \ | |
--network=default \ | |
--action=ALLOW \ | |
--rules=tcp:${PORT} \ | |
--source-ranges=${SOURCE_IP} \ | |
--target-tags=elk-1-tcp-${PORT} |
Part Two
In part one we have examined the creation of the Elastic Stack, the provisioning of the GKE cluster, and the development and deployment of the Spring Boot service to Kubernetes. In part two of this post, we will tie everything together by creating and integrating our Action for Google Assistant:
- Create the new Actions project using the Actions on Google console;
- Develop the Action’s Intents using the Dialogflow console;
- Develop, deploy, and test the Cloud Function to GCP;
Related Posts
If you’re interested in comparing the development of an Action for Google Assistant with that of Amazon’s Alexa and Microsoft’s LUIS-enabled chatbots, in addition to this post, I would recommend the previous three posts in this conversation interface series:
- Building Serverless Actions for Google Assistant with Google Cloud Functions, Cloud Datastore, and Cloud Storage
- Building and Integrating LUIS-enabled Chatbots with Slack, using Azure Bot Service, Bot Builder SDK, and Cosmos DB,
- Building Asynchronous, Serverless Alexa Skills with AWS Lambda, DynamoDB, S3, and Node.js.
All three article’s demonstrations leverage their respective Cloud platform’s machine learning-based Natural language understanding (NLU) services. All three take advantage of their respective Cloud platform’s NoSQL database and object storage services. Lastly, all three of the article’s demonstrations are written in a common language, Node.js.
All opinions expressed in this post are my own and not necessarily the views of my current or past employers, their clients, or Google.
Building Serverless Actions for Google Assistant with Google Cloud Functions, Cloud Datastore, and Cloud Storage
Posted by Gary A. Stafford in Cloud, GCP, JavaScript, Serverless, Software Development on August 11, 2018
Introduction
In this post, we will create an Action for Google Assistant using the ‘Actions on Google’ development platform, Google Cloud Platform’s serverless Cloud Functions, Cloud Datastore, and Cloud Storage, and the current LTS version of Node.js. According to Google, Actions are pieces of software, designed to extend the functionality of the Google Assistant, Google’s virtual personal assistant, across a multitude of Google-enabled devices, including smartphones, cars, televisions, headphones, watches, and smart-speakers.
Here is a brief YouTube video preview of the final Action for Google Assistant, we will explore in this post, running on an Apple iPhone 8.
If you want to compare the development of an Action for Google Assistant with those of AWS and Azure, in addition to this post, please read my previous two posts in this series, Building and Integrating LUIS-enabled Chatbots with Slack, using Azure Bot Service, Bot Builder SDK, and Cosmos DB and Building Asynchronous, Serverless Alexa Skills with AWS Lambda, DynamoDB, S3, and Node.js. All three of the article’s demonstrations are written in Node.js, all three leverage their cloud platform’s machine learning-based Natural Language Understanding services, and all three take advantage of NoSQL database and storage services available on their respective cloud platforms.
Google Technologies
The final architecture of our Action for Google Assistant will look as follows.
Here is a brief overview of the key technologies we will incorporate into our architecture.
Actions on Google
According to Google, Actions on Google is the platform for developers to extend the Google Assistant. Similar to Amazon’s Alexa Skills Kit Development Console for developing Alexa Skills, Actions on Google is a web-based platform that provides a streamlined user-experience to create, manage, and deploy Actions. We will use the Actions on Google platform to develop our Action in this post.
Dialogflow
According to Google, Dialogflow is an enterprise-grade Natural language understanding (NLU) platform that makes it easy for developers to design and integrate conversational user interfaces into mobile apps, web applications, devices, and bots. Dialogflow is powered by Google’s machine learning for Natural Language Processing (NLP). Dialogflow was initially known as API.AI prior being renamed by Google in late 2017.
We will use the Dialogflow web-based development platform and version 2 of the Dialogflow API, which became GA in April 2018, to build our Action for Google Assistant’s rich, natural-language conversational interface.
Google Cloud Functions
Google Cloud Functions are the event-driven serverless compute platform, part of the Google Cloud Platform (GCP). Google Cloud Functions are comparable to Amazon’s AWS Lambda and Azure Functions. Cloud Functions is a relatively new service from Google, released in beta in March 2017, and only recently becoming GA at Cloud Next ’18 (July 2018). The main features of Cloud Functions include automatic scaling, high availability, fault tolerance, no servers to provision, manage, patch or update, and a payment model based on the function’s execution time. The programmatic logic behind our Action for Google Assistant will be handled by a Cloud Function.
Node.js LTS
We will write our Action’s Google Cloud Function using the Node.js 8 runtime. Google just released the ability to write Google Cloud Functions in Node 8.11.1 and Python 3.7.0, at Cloud Next ’18 (July 2018). It is still considered beta functionality. Previously, you had to write your functions in Node version 6 (currently, 6.14.0).
Node 8, also known as Project Carbon, was the first Long Term Support (LTS) version of Node to support async/await with Promises. Async/await is the new way of handling asynchronous operations in Node.js. We will make use of async/await and Promises within our Action’s Cloud Function.
Google Cloud Datastore
Google Cloud Datastore is a highly-scalable NoSQL database. Cloud Datastore is similar in features and capabilities to Azure Cosmos DB and Amazon DynamoDB. Datastore automatically handles sharding and replication and offers features like a RESTful interface, ACID transactions, SQL-like queries, and indexes. We will use Datastore to persist the information returned to the user from our Action for Google Assistant.
Google Cloud Storage
The last technology, Google Cloud Storage is secure and durable object storage, nearly identical to Amazon Simple Storage Service (Amazon S3) and Azure Blob Storage. We will store publicly accessible images in a Google Cloud Storage bucket, which will be displayed in Google Assistant Basic Card responses.
Demonstration
To demonstrate Actions for Google Assistant, we will build an informational Action that responds to the user with interesting facts about Azure, Microsoft’s Cloud computing platform (Google talking about Azure, ironic). Note this is not intended to be an official Microsoft bot and is only used for demonstration purposes.
Source Code
All open-sourced code for this post can be found on GitHub. Note code samples in this post are displayed as Gists, which may not display correctly on some mobile and social media browsers. Links to gists are also provided.
Development Process
This post will focus on the development and integration of an Action with Google Cloud Platform’s serverless and asynchronous Cloud Functions, Cloud Datastore, and Cloud Storage. The post is not intended to be a general how-to on developing and publishing Actions for Google Assistant, or how to specifically use services on the Google Cloud Platform.
Building the Action will involve the following steps.
- Design the Action’s conversation model;
- Import the Azure Facts Entities into Cloud Datastore on GCP;
- Create and upload the images to Cloud Storage on GCP;
- Create the new Actions on Google project using the Actions on Google console;
- Develop the Action’s Intent using the Dialogflow console;
- Bulk import the Action’s Entities using the Dialogflow console;
- Configure the Dialogflow Actions on Google Integration;
- Develop and deploy the Cloud Function to GCP;
- Test the Action using Actions on Google Simulator;
Let’s explore each step in more detail.
Conversational Model
The conversational model design of the Azure Tech Facts Action for Google Assistant is similar to the Azure Tech Facts Alexa Custom Skill, detailed in my previous post. We will have the option to invoke the Action in two ways, without initial intent (Explicit Invocation) and with intent (Implicit Invocation), as shown below. On the left, we see an example of an explicit invocation of the Action. Google Assistant then queries the user for more information. On the right, an implicit invocation of the Action includes the intent, being the Azure fact they want to learn about. Google Assistant responds directly, both verbally and visually with the fact.
Each fact returned by Google Assistant will include a Simple Response, Basic Card and Suggestions response types for devices with a display, as shown below. The user may continue to ask for additional facts or choose to cancel the Action at any time.
Lastly, as part of the conversational model, we will include the option of asking for a random fact, as well as asking for help. Examples of both are shown below. Again, Google Assistant responds to the user, vocally and, optionally, visually, for display-enabled devices.
GCP Account and Project
The following steps assume you have an existing GCP account and you have created a project on GCP to house the Cloud Function, Cloud Storage Bucket, and Cloud Datastore Entities. The post also assumes that you have the Google Cloud SDK installed on your development machine, and have authenticated your identity from the command line (gist).
# Authenticate with the Google Cloud SDK | |
export PROJECT_ID="<your_project_id>" | |
gcloud beta auth login | |
gcloud config set project ${PROJECT_ID} | |
# Update components or new runtime nodejs8 may be unknown | |
gcloud components update |
Google Cloud Storage
First, the images, actually Azure icons available from Microsoft, displayed in the responses shown above, are uploaded to a Google Storage Bucket. To handle these tasks, we will use the gsutil
CLI to create, upload, and manage the images. The gsutil CLI tool, like gcloud
, is part of the Google Cloud SDK. The gsutil mb
(make bucket) command creates the bucket, gsutil cp
(copy files and objects) command is used to copy the images to the new bucket, and finally, the gsutil iam
(get, set, or change bucket and/or object IAM permissions) command is used to make the images public. I have included a shell script, bucket-uploader.sh
, to make this process easier. (gist).
#!/usr/bin/env sh | |
# author: Gary A. Stafford | |
# site: https://programmaticponderings.com | |
# license: MIT License | |
set -ex | |
# Set constants | |
PROJECT_ID="<your_project_id>" | |
REGION="<your_region>" | |
IMAGE_BUCKET="<your_bucket_name>" | |
# Create GCP Storage Bucket | |
gsutil mb \ | |
-p ${PROJECT_ID} \ | |
-c regional \ | |
-l ${REGION} \ | |
gs://${IMAGE_BUCKET} | |
# Upload images to bucket | |
for file in pics/image-*; do | |
gsutil cp ${file} gs://${IMAGE_BUCKET} | |
done | |
# Make all images public in bucket | |
gsutil iam ch allUsers:objectViewer gs://${IMAGE_BUCKET} |
From the Storage Console on GCP, you should observe the images all have publicly accessible URLs. This will allow the Cloud Function to access the bucket, and retrieve and display the images. There are more secure ways to store and display the images from the function. However, this is the simplest method since we are not concerned about making the images public.
We will need the URL of the new Storage bucket, later, when we develop to our Action’s Cloud Function. The bucket URL can be obtained from the Storage Console on GCP, as shown below in the Link URL.
Google Cloud Datastore
In Cloud Datastore, the category data object is referred to as a Kind, similar to a Table in a relational database. In Datastore, we will have an ‘AzureFact’ Kind of data. In Datastore, a single object is referred to as an Entity, similar to a Row in a relational database. Each one of our entities represents a unique reference value from our Azure Facts Intent’s facts entities, such as ‘competition’ and ‘certifications’. Individual data is known as a Property in Datastore, similar to a Column in a relational database. We will have four Properties for each entity: name, response, title, and image. Lastly, a Key in Datastore is similar to a Primary Key in a relational database. The Key we will use for our entities is the unique reference value string from our Azure Facts Intent’s facts entities, such as ‘competition’ or ‘certifications’. The Key value is stored within the entity’s name Property.
There are a number of ways to create the Datastore entities for our Action, including manually from the Datastore console on GCP. However, to automate the process, we will use a script, written in Node.js and using the Google Cloud Datastore Node.js Client, to create the entities. We will use the Client API’s Datastore Class upsert method, which will create or update an entire collection of entities with one call and returns a callback. The script , upsert-entities.js
, is included in source control and can be run with the following command. Below is a snippet of the script, which shows the structure of the entities (gist).
# Upload Google Datastore entities | |
cd data | |
npm install | |
node ./upsert-entities.js |
Once the upsert
command completes successfully, you should observe a collection of ‘AzureFact’ Type Datastore Entities in the Datastore console on GCP.
Below, we see the structure of a single Datastore Entity, the ‘certifications’ Entity, containing the fact response, title, and name of the image, which is stored in our Google Storage bucket.
New ‘Actions on Google’ Project
With the images uploaded and the database entries created, we can start building our Actions for Google Assistant. Using the Actions on Google web console, we first create a new Actions project.
The Directory Information tab is where we define metadata about the project. This information determines how it will look in the Actions directory and is required to publish your project. The Actions directory is where users discover published Actions on the web and mobile devices.
Actions and Intents
Our project will contain a series of related Actions. According to Google, an Action is ‘an interaction you build for the Assistant that supports a specific intent and has a corresponding fulfillment that processes the intent.’ To build our Actions, we first want to create our Intents. To do so, we will want to switch from the Actions on Google console to the Dialogflow console. Actions on Google provides a link for switching to Dialogflow in the Actions tab.
We will build our Action’s Intents in Dialogflow. The term Intent, used by Dialogflow, is standard terminology across other voice-assistant platforms, such as Amazon’s Alexa and Microsoft’s Azure Bot Service and LUIS. In Dialogflow, will be building Intents—the Azure Facts Intent, Welcome Intent, and the Fallback Intent.
Below, we see the Azure Facts Intent. The Azure Facts Intent is the main Intent, responsible for handling our user’s requests for facts about Azure. The Intent includes a fair number, but certainly not an exhaustive list, of training phrases. These represent all the possible ways a user might express intent when invoking the Action. According to Google, the greater the number of natural language examples in the Training Phrases section of Intents, the better the classification accuracy.
Intent Entities
Each of the highlighted words in the training phrases maps to the facts parameter, which maps to a collection of @facts Entities. Entities represent a list of intents the Action is trained to understand. According to Google, there are three types of entities: system (defined by Dialogflow), developer (defined by a developer), and user (built for each individual end-user in every request) entities. We will be creating developer type entities for our Action’s Intent.
Synonyms
An entity contains Synonyms. Multiple synonyms may be mapped to a single reference value. The reference value is the value passed to the Cloud Function by the Action. For example, take the reference value of ‘competition’. A user might ask Google about Azure’s competition. However, the user might also substitute the words ‘competitor’ or ‘competitors’ for ‘competition’. Using synonyms, if the user utters any of these three words in their intent, they will receive the same response.
Although our Azure Facts Action is a simple example, typical Actions might contain hundreds of entities or more, each with several synonyms. Dialogflow provides the option of copy and pasting bulk entities, in either JSON or CSV format. The project’s source code includes both JSON or CSV formats, which may be input in this manner.
Automated Expansion
Not every possible fact, which will have a response, returned by Google Assistant, needs an entity defined. For example, we created a ‘compliance’ Cloud Datastore Entity. The Action understands the term ‘compliance’ and will return a response to the user if they ask about Azure compliance. However, ‘compliance’ is not defined as an Intent Entity, since we have chosen not to define any synonyms for the term ‘compliance’.
In order to allow this, you must enable Allow Automated Expansion. According to Google, this option allows an Agent to recognize values that have not been explicitly listed in the entity. Google describes Agents as NLU (Natural Language Understanding) modules.
Actions on Google Integration
Another configuration item in Dialogflow that needs to be completed is the Dialogflow’s Actions on Google integration. This will integrate the Azure Tech Facts Action with Google Assistant. Google provides more than a dozen different integrations, as shown below.
The Dialogflow’s Actions on Google integration configuration is simple, just choose the Azure Facts Intent as our Action’s Implicit Invocation intent, in addition to the default Welcome Intent, which is our Action’s Explicit Invocation intent. According to Google, integration allows our Action to reach users on every device where the Google Assistant is available.
Action Fulfillment
When an intent is received from the user, it is fulfilled by the Action. In the Dialogflow Fulfillment console, we see the Action has two fulfillment options, a Webhook or a Cloud Function, which can be edited inline. A Webhook allows us to pass information from a matched intent into a web service and get a result back from the service. In our example, our Action’s Webhook will call our Cloud Function, using the Cloud Function’s URL endpoint. We first need to create our function in order to get the endpoint, which we will do next.
Google Cloud Functions
Our Cloud Function, called by our Action, is written in Node.js 8. As stated earlier, Node 8 LTS was the first LTS version to support async/await with Promises. Async/await is the new way of handling asynchronous operations in Node.js, replacing callbacks.
Our function, index.js, is divided into four sections: constants, intent handlers, helper functions, and the function’s entry point. The Cloud Function attempts to follow many of the coding practices from Google’s code examples on Github.
Constants
The section defines the global constants used within the function. Note the constant for the URL of our new Cloud Storage bucket, on line 30 below, IMAGE_BUCKET
, references an environment variable, process.env.IMAGE_BUCKET
. This value is set in the .env.yaml
file. All environment variables in the .env.yaml
file will be set during the Cloud Function’s deployment, explained later in this post. Environment variables were recently released, and are still considered beta functionality (gist).
// author: Gary A. Stafford | |
// site: https://programmaticponderings.com | |
// license: MIT License | |
'use strict'; | |
/* CONSTANTS */ | |
const { | |
dialogflow, | |
Suggestions, | |
BasicCard, | |
SimpleResponse, | |
Image, | |
} = require('actions-on-google'); | |
const functions = require('firebase-functions'); | |
const Datastore = require('@google-cloud/datastore'); | |
const datastore = new Datastore({}); | |
const app = dialogflow({debug: true}); | |
app.middleware(conv => { | |
conv.hasScreen = | |
conv.surface.capabilities.has('actions.capability.SCREEN_OUTPUT'); | |
conv.hasAudioPlayback = | |
conv.surface.capabilities.has('actions.capability.AUDIO_OUTPUT'); | |
}); | |
const IMAGE_BUCKET = process.env.IMAGE_BUCKET; | |
const SUGGESTION_1 = 'tell me a random fact'; | |
const SUGGESTION_2 = 'help'; | |
const SUGGESTION_3 = 'cancel'; |
The npm package dependencies declared in the constants section, are defined in the dependencies section of the package.json
file. Function dependencies include Actions on Google, Firebase Functions, and Cloud Datastore (gist).
"dependencies": { | |
"@google-cloud/datastore": "^1.4.1", | |
"actions-on-google": "^2.2.0", | |
"dialogflow": "^0.6.0", | |
"dialogflow-fulfillment": "^0.5.0", | |
"firebase-admin": "^6.0.0", | |
"firebase-functions": "^2.0.2" | |
} |
Intent Handlers
The three intent handlers correspond to the three intents in the Dialogflow console: Azure Facts Intent, Welcome Intent, and Fallback Intent. Each handler responds in a very similar fashion. The handlers all return a SimpleResponse
for audio-only and display-enabled devices. Optionally, a BasicCard
is returned for display-enabled devices (gist).
/* INTENT HANDLERS */ | |
app.intent('Welcome Intent', conv => { | |
const WELCOME_TEXT_SHORT = 'What would you like to know about Microsoft Azure?'; | |
const WELCOME_TEXT_LONG = `What would you like to know about Microsoft Azure? ` + | |
`You can say things like: \n` + | |
` _'tell me about Azure certifications'_ \n` + | |
` _'when was Azure released'_ \n` + | |
` _'give me a random fact'_`; | |
const WELCOME_IMAGE = 'image-16.png'; | |
conv.ask(new SimpleResponse({ | |
speech: WELCOME_TEXT_SHORT, | |
text: WELCOME_TEXT_SHORT, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
text: WELCOME_TEXT_LONG, | |
title: 'Azure Tech Facts', | |
image: new Image({ | |
url: `${IMAGE_BUCKET}/${WELCOME_IMAGE}`, | |
alt: 'Azure Tech Facts', | |
}), | |
display: 'WHITE', | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Fallback Intent', conv => { | |
const FACTS_LIST = "Certifications, Cognitive Services, Competition, Compliance, First Offering, Functions, " + | |
"Geographies, Global Infrastructure, Platforms, Categories, Products, Regions, and Release Date"; | |
const WELCOME_TEXT_SHORT = 'Need a little help?'; | |
const WELCOME_TEXT_LONG = `Current facts include: ${FACTS_LIST}.`; | |
const WELCOME_IMAGE = 'image-15.png'; | |
conv.ask(new SimpleResponse({ | |
speech: WELCOME_TEXT_LONG, | |
text: WELCOME_TEXT_SHORT, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
text: WELCOME_TEXT_LONG, | |
title: 'Azure Tech Facts Help', | |
image: new Image({ | |
url: `${IMAGE_BUCKET}/${WELCOME_IMAGE}`, | |
alt: 'Azure Tech Facts', | |
}), | |
display: 'WHITE', | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
app.intent('Azure Facts Intent', async (conv, {facts}) => { | |
let factToQuery = facts.toString(); | |
let fact = await buildFactResponse(factToQuery); | |
const AZURE_TEXT_SHORT = `Sure, here's a fact about ${fact.title}`; | |
conv.ask(new SimpleResponse({ | |
speech: fact.response, | |
text: AZURE_TEXT_SHORT, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
text: fact.response, | |
title: fact.title, | |
image: new Image({ | |
url: `${IMAGE_BUCKET}/${fact.image}`, | |
alt: fact.title, | |
}), | |
display: 'WHITE', | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); |
The Welcome Intent handler handles explicit invocations of our Action. The Fallback Intent handler handles both help requests, as well as cases when Dialogflow cannot match any of the user’s input. Lastly, the Azure Facts Intent handler handles implicit invocations of our Action, returning a fact to the user from Cloud Datastore, based on the user’s requested fact.
Helper Functions
The next section of the function contains two helper functions. The primary function is the buildFactResponse
function. This is the function that queries Google Cloud Datastore for the fact. The second function, the selectRandomFact
, handles the fact value of ‘random’, by selecting a random fact value to query Datastore. (gist).
/* HELPER FUNCTIONS */ | |
function selectRandomFact() { | |
const FACTS_ARRAY = ['description', 'released', 'global', 'regions', | |
'geographies', 'platforms', 'categories', 'products', 'cognitive', | |
'compliance', 'first', 'certifications', 'competition', 'functions']; | |
return FACTS_ARRAY[Math.floor(Math.random() * FACTS_ARRAY.length)]; | |
} | |
function buildFactResponse(factToQuery) { | |
return new Promise((resolve, reject) => { | |
if (factToQuery.toString().trim() === 'random') { | |
factToQuery = selectRandomFact(); | |
} | |
const query = datastore | |
.createQuery('AzureFact') | |
.filter('__key__', '=', datastore.key(['AzureFact', factToQuery])); | |
datastore | |
.runQuery(query) | |
.then(results => { | |
resolve(results[0][0]); | |
}) | |
.catch(err => { | |
console.log(`Error: ${err}`); | |
reject(`Sorry, I don't know the fact, ${factToQuery}.`); | |
}); | |
}); | |
} | |
/* ENTRY POINT */ | |
exports.functionAzureFactsAction = functions.https.onRequest(app); |
Async/Await, Promises, and Callbacks
Let’s look closer at the relationship and asynchronous nature of the Azure Facts Intent intent handler and buildFactResponse
function. Below, note the async
function on line 1 in the intent and the await
function on line 3, which is part of the buildFactResponse
function call. This is typically how we see async/await applied when calling an asynchronous function, such as buildFactResponse
. The await
function allows the intent’s execution to wait for the buildFactResponse
function’s Promise to be resolved, before attempting to use the resolved value to construct the response.
The buildFactResponse
function returns a Promise, as seen on line 28. The Promise’s payload contains the results of the successful callback from the Datastore API’s runQuery
function. The runQuery
function returns a callback, which is then resolved and returned by the Promise, as seen on line 40 (gist).
app.intent('Azure Facts Intent', async (conv, {facts}) => { | |
let factToQuery = facts.toString(); | |
let fact = await buildFactResponse(factToQuery); | |
const AZURE_TEXT_SHORT = `Sure, here's a fact about ${fact.title}`; | |
conv.ask(new SimpleResponse({ | |
speech: fact.response, | |
text: AZURE_TEXT_SHORT, | |
})); | |
if (conv.hasScreen) { | |
conv.ask(new BasicCard({ | |
text: fact.response, | |
title: fact.title, | |
image: new Image({ | |
url: `${IMAGE_BUCKET}/${fact.image}`, | |
alt: fact.title, | |
}), | |
display: 'WHITE', | |
})); | |
conv.ask(new Suggestions([SUGGESTION_1, SUGGESTION_2, SUGGESTION_3])); | |
} | |
}); | |
function buildFactResponse(factToQuery) { | |
return new Promise((resolve, reject) => { | |
if (factToQuery.toString().trim() === 'random') { | |
factToQuery = selectRandomFact(); | |
} | |
const query = datastore | |
.createQuery('AzureFact') | |
.filter('__key__', '=', datastore.key(['AzureFact', factToQuery])); | |
datastore | |
.runQuery(query) | |
.then(results => { | |
resolve(results[0][0]); | |
}) | |
.catch(err => { | |
console.log(`Error: ${err}`); | |
reject(`Sorry, I don't know the fact, ${factToQuery}.`); | |
}); | |
}); | |
} |
The payload returned by Google Datastore, through the resolved Promise to the intent handler, will resemble the example response, shown below. Note the image
, response
, and title
key/value pairs in the textPayload
section of the response payload. These are what are used to format the SimpleResponse
and BasicCard
responses (gist).
{ | |
title: 'Azure Functions', | |
image: 'image-14.png', | |
response: 'According to Microsoft, Azure Functions is a serverless compute service that enables you to run code on-demand without having to explicitly provision or manage infrastructure.', | |
[Symbol(KEY)]: Key { | |
namespace: undefined, | |
name: 'functions', | |
kind: 'AzureFact', | |
path: [Getter] | |
} | |
} |
Cloud Function Deployment
To deploy the Cloud Function to GCP, use the gcloud
CLI with the beta version of the functions deploy command. According to Google, gcloud
is a part of the Google Cloud SDK. You must download and install the SDK on your system and initialize it before you can use gcloud
. You should ensure that your function is deployed to the same region as your Google Storage Bucket. Currently, Cloud Functions are only available in four regions. I have included a shell script, deploy-cloud-function.sh
, to make this step easier. (gist).
#!/usr/bin/env sh | |
# author: Gary A. Stafford | |
# site: https://programmaticponderings.com | |
# license: MIT License | |
set -ex | |
# Set constants | |
REGION="<your_region>" | |
FUNCTION_NAME="<your_function_name>" | |
# Deploy the Google Cloud Function | |
gcloud beta functions deploy ${FUNCTION_NAME} \ | |
--runtime nodejs8 \ | |
--region ${REGION} \ | |
--trigger-http \ | |
--memory 256MB \ | |
--env-vars-file .env.yaml |
The creation or update of the Cloud Function can take up to two minutes. Note the .gcloudignore
file referenced in the verbose output below. This file is created the first time you deploy a new function. Using the the .gcloudignore
file, you can limit the deployed files to just the function (index.js
) and the package.json
file. There is no need to deploy any other files to GCP.
If you recall, the URL endpoint of the Cloud Function is required in the Dialogflow Fulfillment tab. The URL can be retrieved from the deployment output (shown above), or from the Cloud Functions Console on GCP (shown below). The Cloud Function is now deployed and will be called by the Action when a user invokes the Action.
Simulation Testing and Debugging
With our Action and all its dependencies deployed and configured, we can test the Action using the Simulation console on Actions on Google. According to Google, the Action Simulation console allows us to manually test our Action by simulating a variety of Google-enabled hardware devices and their settings. You can also access debug information such as the request and response that your fulfillment receives and sends.
Below, in the Action Simulation console, we see the successful display of the initial Azure Tech Facts containing the expected Simple Response, Basic Card, and Suggestions, triggered by a user’s explicit invocation of the Action.
The simulated response indicates that the Google Cloud Function was called, and it responded successfully. It also indicates that the Google Cloud Function was able to successfully retrieve the correct image from Google Cloud Storage.
Below, we see the successful response to the user’s implicit invocation of the Action, in which they are seeking a fact about Azure’s Cognitive Services. The simulated response indicates that the Google Cloud Function was called, and it responded successfully. It also indicates that the Google Cloud Function was able to successfully retrieve the correct Entity from Google Cloud Datastore, as well as the correct image from Google Cloud Storage.
If we had issues with the testing, the Action Simulation console also contains tabs containing the request and response objects sent to and from the Cloud Function, the audio response, a debug console, and any errors.
Logging and Analytics
In addition to the Simulation console’s ability to debug issues with our service, we also have Google Stackdriver Logging. The Stackdriver logs, which are viewed from the GCP management console, contain the complete requests and responses, to and from the Cloud Function, from the Google Assistant Action. The Stackdriver logs will also contain any logs entries you have explicitly placed in the Cloud Function.
We also have the ability to view basic Analytics about our Action from within the Dialogflow Analytics console. Analytics displays metrics, such as the number of sessions, the number of queries, the number of times each Intent was triggered, how often users exited the Action from an intent, and Sessions flows, shown below.
In simple Action such as this one, the Session flow is not very beneficial. However, in more complex Actions, with multiple Intents and a variety potential user interactions, being able to visualize Session flows becomes essential to understanding the user’s conversational path through the Action.
Conclusion
In this post, we have seen how to use the Actions on Google development platform and the latest version of the Dialogflow API to build Google Actions. Google Actions rather effortlessly integrate with the breath Google Cloud Platform’s many serverless offerings, including Google Cloud Functions, Cloud Datastore, and Cloud Storage.
We have seen how Google is quickly maturing their serverless functions, to compete with AWS and Azure, with the recently announced support of LTS version 8 of Node.js and Python, to create an Actions for Google Assistant.
Impact of Serverless
As an Engineer, I have spent endless days, late nights, and thankless weekends, building, deploying and managing servers, virtual machines, container clusters, persistent storage, and database servers. I think what is most compelling about platforms like Actions on Google, but even more so, serverless technologies on GCP, is that I spend the majority of my time architecting and developing compelling software. I don’t spend time managing infrastructure, worrying about capacity, configuring networking and security, and doing DevOps.
¹Azure is a trademark of Microsoft
All opinions expressed in this post are my own and not necessarily the views of my current or past employers, their clients, or Google and Microsoft.