Creating a chatbot that is capable of a meaningful conversation will require language understanding (LUIS). However, if you are using LUIS and then parsing the response yourself, you might notice that it can get complicated pretty fast. This is because the response from LUIS is dynamic since the response is based on the utterance passed to it.
So, you probably have the thought going through your mind, “There got to be a better way to do this.” Guess, what there is, but it is not that clearly defined. In this post, I am going to show you another way to work LUIS that will give you a strongly typed response from LUIS so you don’t need to parse anything yourself.
Prerequisites
You will need the following:
- Visual Studio 2017
- Microsoft Azure account
- LUIS app
- LUISGen
- LUDown (optional – I will not be using this for the example)
Example
For this example, I will be working with the auto-generated LUIS app when you create your web app bot on Azure. I will be adding in the MovieTickets prebuilt domain into the LUIS app. So, the bot can understand utterances about booking a ticket for a movie and finding out the show time of a movie. I will not be building on the bot to actually handle the requests for this example. I will only have the bot understand statements that are related to purchasing a movie ticket and getting show time — I’ll return a fake reply as an acknowledgment.
Updating LUIS App
To update the LUIS app with prebuilt MovieTickets domain, navigate to the prebuilt domain option in the left blade menu in the LUIS app.
Once the domain is added, you will need to train before the LUIS app can recognize utterances pertaining to movie tickets. After training, test out the LUIS app, it should be able to identify utterances that involve buying a movie ticket or asking for the show time of a movie.
Once you verified the LUIS app is working, you need to publish the LUIS app. If you don’t publish, the bot will not be able to talk to this updated version of the LUIS app.
Getting the New LUIS Result in the Bot
Without changing the code for the bot, the return result from a LUIS API call will reflect the most recent published version of the LUIS app. That means if you type in utterances that are categorized as MovieTicket.Book or MovieTickets.GetShowTime intent the result will contain it. However, the bot can’t do anything with the result until you add some logic to handle for those intent cases (more on that later in the post).
In addition, sometimes getting the intent is not enough because you might want to know the entities as well. Obtaining information from the LUIS result requires a lot of parsing since it is dynamic, but luckily there is a tool (LUISGen) provided to make the process easier.
LUISGen
The LUISGen tool is a command line tool that generates a class object from a LUIS app JSON. This generated class is strongly typed based on all the entities and intents in the LUIS app. When making a LUIS API call, you can ask for the call to return the generated class object type. Behind-the-scene the JSON response from LUIS will get deserialize into the generated object by LUISGen.
Using LUISGen to Generate LUIS Class Model Object
Make sure you have LUISGen installed. If not please follow the instructions to install LUISGen in the readme for the project.
The next step is to get the JSON file of your LUIS app from the LUIS portal. To get the JSON file, go to Manage > Versions > Pick the version that is published > Export.
Now you’re done with the LUIS app side. The rest will focus on the bot side. In the bot project, add a new folder and name it Luis or whatever you want. In the new folder, add an existing file and select the JSON file of the LUIS app. For this example, the LUIS app JSON file is named bd-basic-bot-luis-movietickets.json.
Now it’s time to generate the class model object for the LUIS app from the JSON. Open up the terminal and navigate to where the LUIS app JSON is located.
Run LUISGen to generate the class object
luisgen ./bd-basic-bot-luis-movietickets.json -cs LuisMovieTicketModel
Once the class object is generated, open up the file and update the namespace to be the same as the rest of the project.
Using the Generated LUIS Model Class
To use the generated LUIS model class you will need to update the API call to the LUIS service so that the result will be deserialized into the generated model class.
var luisResults = await _services.LuisServices[LuisConfiguration].RecognizeAsync<LuisMovieTicketModel>(dc.Context, cancellationToken).ConfigureAwait(false);
Once the API call returns, the return value will be the generated LUIS model class. From the returned object, you can access the fields that you need. Make sure to use the null-conditional operator (?.) since not all the fields will have a result.
Specifically for the example, we’ll add two intent cases for the movietickets domain. In each of the case, we’ll try to reply back with as much information as possible.
// ... case LuisMovieTicketModel.Intent.MovieTickets_GetShowTime: { string movieTitle = luisResults.Entities.MovieTickets_MovieTitle?.FirstOrDefault(); string response = $"{movieTitle} is showing tonight at 8:00 PM in the AMC theater near you."; await dc.Context.SendActivityAsync(response); break; } case LuisMovieTicketModel.Intent.MovieTickets_Book: { string movieTitle = luisResults.Entities.MovieTickets_MovieTitle?.FirstOrDefault(); string showingLocationName = luisResults.Entities.MovieTickets_PlaceName?.FirstOrDefault(); string response = $"I got you a ticket for {movieTitle} at {showingLocationName} for tonight"; await dc.Context.SendActivityAsync(response); break; } // ...
With the changes build and run the bot. Talk to the bot with the bot framework emulator.
// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // See https://github.com/microsoft/botbuilder-samples for a more comprehensive list of samples. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Schema; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace Microsoft.BotBuilderSamples { /// <summary> /// Main entry point and orchestration for bot. /// </summary> public class BasicBot : IBot { /// <summary> /// Key in the bot config (.bot file) for the LUIS instance. /// In the .bot file, multiple instances of LUIS can be configured. /// </summary> public static readonly string LuisConfiguration = "BasicBotLuisApplication"; private readonly IStatePropertyAccessor<GreetingState> _greetingStateAccessor; private readonly IStatePropertyAccessor<DialogState> _dialogStateAccessor; private readonly UserState _userState; private readonly ConversationState _conversationState; private readonly BotServices _services; /// <summary> /// Initializes a new instance of the <see cref="BasicBot"/> class. /// </summary> /// <param name="botServices">Bot services.</param> /// <param name="accessors">Bot State Accessors.</param> public BasicBot(BotServices services, UserState userState, ConversationState conversationState, ILoggerFactory loggerFactory) { _services = services ?? throw new ArgumentNullException(nameof(services)); _userState = userState ?? throw new ArgumentNullException(nameof(userState)); _conversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState)); _greetingStateAccessor = _userState.CreateProperty<GreetingState>(nameof(GreetingState)); _dialogStateAccessor = _conversationState.CreateProperty<DialogState>(nameof(DialogState)); // Verify LUIS configuration. if (!_services.LuisServices.ContainsKey(LuisConfiguration)) { throw new InvalidOperationException($"The bot configuration does not contain a service type of `luis` with the id `{LuisConfiguration}`."); } Dialogs = new DialogSet(_dialogStateAccessor); Dialogs.Add(new GreetingDialog(_greetingStateAccessor, loggerFactory)); } private DialogSet Dialogs { get; set; } /// <summary> /// Run every turn of the conversation. Handles orchestration of messages. /// </summary> /// <param name="turnContext">Bot Turn Context.</param> /// <param name="cancellationToken">Task CancellationToken.</param> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> public async Task OnTurnAsync(ITurnContext turnContext, CancellationToken cancellationToken) { var activity = turnContext.Activity; // Create a dialog context var dc = await Dialogs.CreateContextAsync(turnContext); if (activity.Type == ActivityTypes.Message) { // Perform a call to LUIS to retrieve results for the current activity message. var luisResults = await _services.LuisServices[LuisConfiguration].RecognizeAsync<LuisMovieTicketModel>(dc.Context, cancellationToken).ConfigureAwait(false); // If any entities were updated, treat as interruption. // For example, "no my name is tony" will manifest as an update of the name to be "tony". var topScoringIntent = luisResults.TopIntent(); var topIntent = topScoringIntent.intent; // update greeting state with any entities captured await UpdateGreetingState(luisResults, dc.Context); // Handle conversation interrupts first. var interrupted = await IsTurnInterruptedAsync(dc, topIntent); if (interrupted) { // Bypass the dialog. // Save state before the next turn. await _conversationState.SaveChangesAsync(turnContext); await _userState.SaveChangesAsync(turnContext); return; } // Continue the current dialog var dialogResult = await dc.ContinueDialogAsync(); // if no one has responded, if (!dc.Context.Responded) { // examine results from active dialog switch (dialogResult.Status) { case DialogTurnStatus.Empty: switch (topIntent) { case LuisMovieTicketModel.Intent.Greeting: await dc.BeginDialogAsync(nameof(GreetingDialog)); break; case LuisMovieTicketModel.Intent.MovieTickets_GetShowTime: { string movieTitle = luisResults.Entities.MovieTickets_MovieTitle?.FirstOrDefault(); string response = $"{movieTitle} is showing tonight at 8:00 PM in the AMC theater near you."; await dc.Context.SendActivityAsync(response); break; } case LuisMovieTicketModel.Intent.MovieTickets_Book: { string movieTitle = luisResults.Entities.MovieTickets_MovieTitle?.FirstOrDefault(); string showingLocationName = luisResults.Entities.MovieTickets_PlaceName?.FirstOrDefault(); string response = $"I got you a ticket for {movieTitle} at {showingLocationName} for tonight"; await dc.Context.SendActivityAsync(response); break; } case LuisMovieTicketModel.Intent.None: default: // Help or no intent identified, either way, let's provide some help. // to the user await dc.Context.SendActivityAsync("I didn't understand what you just said to me."); break; } break; case DialogTurnStatus.Waiting: // The active dialog is waiting for a response from the user, so do nothing. break; case DialogTurnStatus.Complete: await dc.EndDialogAsync(); break; default: await dc.CancelAllDialogsAsync(); break; } } } else if (activity.Type == ActivityTypes.ConversationUpdate) { if (activity.MembersAdded.Any()) { // Iterate over all new members added to the conversation. foreach (var member in activity.MembersAdded) { // Greet anyone that was not the target (recipient) of this message. // To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. if (member.Id != activity.Recipient.Id) { var welcomeCard = CreateAdaptiveCardAttachment(); var response = CreateResponse(activity, welcomeCard); await dc.Context.SendActivityAsync(response).ConfigureAwait(false); } } } } await _conversationState.SaveChangesAsync(turnContext); await _userState.SaveChangesAsync(turnContext); } // Determine if an interruption has occured before we dispatch to any active dialog. private async Task<bool> IsTurnInterruptedAsync(DialogContext dc, LuisMovieTicketModel.Intent topIntent) { // See if there are any conversation interrupts we need to handle. if (topIntent.Equals(LuisMovieTicketModel.Intent.Cancel)) { if (dc.ActiveDialog != null) { await dc.CancelAllDialogsAsync(); await dc.Context.SendActivityAsync("Ok. I've cancelled our last activity."); } else { await dc.Context.SendActivityAsync("I don't have anything to cancel."); } return true; // Handled the interrupt. } if (topIntent.Equals(LuisMovieTicketModel.Intent.None)) { await dc.Context.SendActivityAsync("Let me try to provide some help."); await dc.Context.SendActivityAsync("I understand greetings, being asked for help, or being asked to cancel what I am doing."); if (dc.ActiveDialog != null) { await dc.RepromptDialogAsync(); } return true; // Handled the interrupt. } return false; // Did not handle the interrupt. } // Create an attachment message response. private Activity CreateResponse(Activity activity, Attachment attachment) { var response = activity.CreateReply(); response.Attachments = new List<Attachment>() { attachment }; return response; } // Load attachment from file. private Attachment CreateAdaptiveCardAttachment() { var adaptiveCard = File.ReadAllText($@".{Path.DirectorySeparatorChar}Dialogs{Path.DirectorySeparatorChar}Welcome{Path.DirectorySeparatorChar}Resources{Path.DirectorySeparatorChar}welcomeCard.json"); return new Attachment() { ContentType = "application/vnd.microsoft.card.adaptive", Content = JsonConvert.DeserializeObject(adaptiveCard), }; } /// <summary> /// Helper function to update greeting state with entities returned by LUIS. /// </summary> /// <param name="luisResult">LUIS recognizer <see cref="RecognizerResult"/>.</param> /// <param name="turnContext">A <see cref="ITurnContext"/> containing all the data needed /// for processing this conversation turn.</param> /// <returns>A task that represents the work queued to execute.</returns> private async Task UpdateGreetingState(LuisMovieTicketModel luisResult, ITurnContext turnContext) { if (luisResult.Entities != null) { // Get latest GreetingState var greetingState = await _greetingStateAccessor.GetAsync(turnContext, () => new GreetingState()); var entities = luisResult.Entities; // Supported LUIS Entities string[] userNameEntities = { "userName", "userName_paternAny" }; string[] userLocationEntities = { "userLocation", "userLocation_patternAny" }; // Update any entities // Note: Consider a confirm dialog, instead of just updating. if (entities.userName?.Any() is true) { var newName = entities.userName.FirstOrDefault(); greetingState.Name = char.ToUpper(newName[0]) + newName.Substring(1); } if (entities.userName_paternAny?.Any() is true) { var newName = entities.userName_paternAny.FirstOrDefault(); greetingState.Name = char.ToUpper(newName[0]) + newName.Substring(1); } if (entities.userLocation?.Any() is true) { var newCity = entities.userLocation.FirstOrDefault(); greetingState.City = char.ToUpper(newCity[0]) + newCity.Substring(1); } if (entities.userLocation_patternAny?.Any() is true) { var newCity = entities.userLocation_patternAny.FirstOrDefault(); greetingState.City = char.ToUpper(newCity[0]) + newCity.Substring(1); } // Set the new values into state. await _greetingStateAccessor.SetAsync(turnContext, greetingState); } } } }
I hope this post was helpful to you. If you found this post helpful, share it with others so they can benefit too.
What are your experiences working with the bot framework and LUIS?
To get in touch, you can follow me on Twitter, leave a comment, or send me an email at steven@brightdevelopers.com.