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.