diff --git a/libraries/Microsoft.Bot.Builder.AI.QnA/Dialogs/QnAMakerDialog.cs b/libraries/Microsoft.Bot.Builder.AI.QnA/Dialogs/QnAMakerDialog.cs index c777c3b02..c98b8374f 100644 --- a/libraries/Microsoft.Bot.Builder.AI.QnA/Dialogs/QnAMakerDialog.cs +++ b/libraries/Microsoft.Bot.Builder.AI.QnA/Dialogs/QnAMakerDialog.cs @@ -102,6 +102,7 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs /// of the source file that contains the caller. /// The line number, for debugging. Defaults to the line number /// in the source file at which the method is called. + /// Boolean value to determine whether an Adaptive card formatted for Teams should be used for responses. public QnAMakerDialog( string dialogId, string knowledgeBaseId, @@ -118,7 +119,8 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs ServiceType qnAServiceType = ServiceType.QnAMaker, HttpClient httpClient = null, [CallerFilePath] string sourceFilePath = "", - [CallerLineNumber] int sourceLineNumber = 0) + [CallerLineNumber] int sourceLineNumber = 0, + bool useTeamsAdaptiveCard = false) : base(dialogId) { this.RegisterSourceLocation(sourceFilePath, sourceLineNumber); @@ -135,6 +137,7 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs Filters = filters; QnAServiceType = qnAServiceType; this.HttpClient = httpClient; + this.UseTeamsAdaptiveCard = useTeamsAdaptiveCard; // add waterfall steps this.AddStep(CallGenerateAnswerAsync); @@ -169,6 +172,7 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs /// of the source file that contains the caller. /// The line number, for debugging. Defaults to the line number /// in the source file at which the method is called. + /// Boolean value to determine whether an Adaptive card formatted for Teams should be used for responses. public QnAMakerDialog( string knowledgeBaseId, string endpointKey, @@ -184,7 +188,8 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs ServiceType qnAServiceType = ServiceType.QnAMaker, HttpClient httpClient = null, [CallerFilePath] string sourceFilePath = "", - [CallerLineNumber] int sourceLineNumber = 0) + [CallerLineNumber] int sourceLineNumber = 0, + bool useTeamsAdaptiveCard = false) : this( nameof(QnAMakerDialog), knowledgeBaseId, @@ -201,7 +206,8 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs qnAServiceType, httpClient, sourceFilePath, - sourceLineNumber) + sourceLineNumber, + useTeamsAdaptiveCard) { } @@ -395,6 +401,15 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs [JsonProperty("displayPreciseAnswerOnly")] public BoolExpression DisplayPreciseAnswerOnly { get; set; } = false; + /// + /// Gets or sets a value indicating whether the dialog response should use a MS Teams formatted Adaptive Card instead of a Hero Card. + /// + /// + /// True/False, defaults to False. + /// + [JsonProperty("useTeamsAdaptiveCard")] + public BoolExpression UseTeamsAdaptiveCard { get; set; } = false; + /// /// Gets or sets QnA Service type to query either QnAMaker or Custom Question Answering Knowledge Base. /// @@ -614,7 +629,7 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs var response = (List)stepContext.Result; if (response.Count > 0 && response[0].Id != -1) { - var message = QnACardBuilder.GetQnADefaultResponse(response.First(), dialogOptions.ResponseOptions.DisplayPreciseAnswerOnly); + var message = QnACardBuilder.GetQnADefaultResponse(response.First(), dialogOptions.ResponseOptions.DisplayPreciseAnswerOnly, UseTeamsAdaptiveCard); await stepContext.Context.SendActivityAsync(message).ConfigureAwait(false); } else @@ -629,7 +644,7 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs if (response.Count == 1 && response[0].Id == -1) { // Nomatch Response from service. - var message = QnACardBuilder.GetQnADefaultResponse(response.First(), dialogOptions.ResponseOptions.DisplayPreciseAnswerOnly); + var message = QnACardBuilder.GetQnADefaultResponse(response.First(), dialogOptions.ResponseOptions.DisplayPreciseAnswerOnly, UseTeamsAdaptiveCard); await stepContext.Context.SendActivityAsync(message).ConfigureAwait(false); } else @@ -839,7 +854,7 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs ObjectPath.SetPathValue(stepContext.ActiveDialog.State, Options, dialogOptions); // Get multi-turn prompts card activity. - var message = QnACardBuilder.GetQnADefaultResponse(answer, dialogOptions.ResponseOptions.DisplayPreciseAnswerOnly); + var message = QnACardBuilder.GetQnADefaultResponse(answer, dialogOptions.ResponseOptions.DisplayPreciseAnswerOnly, UseTeamsAdaptiveCard); await stepContext.Context.SendActivityAsync(message).ConfigureAwait(false); return new DialogTurnResult(DialogTurnStatus.Waiting); diff --git a/libraries/Microsoft.Bot.Builder.AI.QnA/Utils/QnACardBuilder.cs b/libraries/Microsoft.Bot.Builder.AI.QnA/Utils/QnACardBuilder.cs index 1b2b41622..8001a31e4 100644 --- a/libraries/Microsoft.Bot.Builder.AI.QnA/Utils/QnACardBuilder.cs +++ b/libraries/Microsoft.Bot.Builder.AI.QnA/Utils/QnACardBuilder.cs @@ -94,8 +94,9 @@ namespace Microsoft.Bot.Builder.AI.QnA /// /// Result to be dispalyed as prompts. /// Choice to render precise answer. + /// Choose whether to use a Teams-formatted Adaptive card. /// IMessageActivity. - public static IMessageActivity GetQnADefaultResponse(QueryResult result, BoolExpression displayPreciseAnswerOnly) + public static IMessageActivity GetQnADefaultResponse(QueryResult result, BoolExpression displayPreciseAnswerOnly, BoolExpression useTeamsAdaptiveCard = null) { if (result == null) { @@ -126,7 +127,7 @@ namespace Microsoft.Bot.Builder.AI.QnA } } - string heroCardText = null; + string cardText = null; if (!string.IsNullOrWhiteSpace(result?.AnswerSpan?.Text)) { chatActivity.Text = result.AnswerSpan.Text; @@ -134,31 +135,122 @@ namespace Microsoft.Bot.Builder.AI.QnA // For content choice Precise only if (displayPreciseAnswerOnly.Value == false) { - heroCardText = result.Answer; + cardText = result.Answer; } } - if (buttonList != null || !string.IsNullOrWhiteSpace(heroCardText)) + if (buttonList != null || !string.IsNullOrWhiteSpace(cardText)) { - var plCard = new HeroCard(); + var useAdaptive = useTeamsAdaptiveCard == null ? false : useTeamsAdaptiveCard.Value; + var cardAttachment = useAdaptive ? CreateAdaptiveCardAttachment(cardText, buttonList) : CreateHeroCardAttachment(cardText, buttonList); - if (buttonList != null) - { - plCard.Buttons = buttonList; - } - - if (!string.IsNullOrWhiteSpace(heroCardText)) - { - plCard.Text = heroCardText; - } - - // Create the attachment. - var attachment = plCard.ToAttachment(); - - chatActivity.Attachments.Add(attachment); + chatActivity.Attachments.Add(cardAttachment); } return chatActivity; } + + /// + /// Get a Teams-formatted Adaptive Card as Attachment to be returned in the QnA response. Max width and height of response are controlled by Teams. + /// + /// String of text to be added to the card. + /// List of CardAction representing buttons to be added to the card. + /// Attachment. + private static Attachment CreateAdaptiveCardAttachment(string cardText, List buttonList) + { + // If there are buttons, create an array of buttons for the card. + // Each button is represented by a Dictionary containing the required fields for each button. + var cardButtons = buttonList?.Select(button => + new Dictionary + { + { "type", "Action.Submit" }, + { "title", button.Title }, + { + "data", + new Dictionary + { + { + "msteams", + new Dictionary + { + { "type", "messageBack" }, + { "displayText", button.DisplayText }, + { "text", button.Text }, + { "value", button.Value } + } + } + } + } + }).ToArray(); + + // Create a dictionary to represent the completed Adaptive card + // msteams field is also a dictionary + // body field is an array containing a dictionary + var card = new Dictionary + { + { "$schema", "http://adaptivecards.io/schemas/adaptive-card.json" }, + { "type", "AdaptiveCard" }, + { "version", "1.3" }, + { + "msteams", + new Dictionary + { + { "width", "full" }, + { "height", "full" } + } + }, + { + "body", + new Dictionary[] + { + new Dictionary + { + { "type", "TextBlock" }, + { "text", (!string.IsNullOrWhiteSpace(cardText) ? cardText : string.Empty) } + } + } + } + }; + + // If there are buttons, add the buttons array to the card. "actions" must be formatted as an array. + if (cardButtons != null) + { + card.Add("actions", cardButtons); + } + + // Create and return the card as an attachment + var adaptiveCard = new Attachment() + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = card + }; + + return adaptiveCard; + } + + /// + /// Get a Hero Card as Attachment to be returned in the QnA response. + /// + /// string of text to be added to the card. + /// List of CardAction representing buttons to be added to the card. + /// Attachment. + private static Attachment CreateHeroCardAttachment(string cardText, List buttonList) + { + // Create a new hero card, add the text and buttons if they exist + var card = new HeroCard(); + + if (buttonList != null) + { + card.Buttons = buttonList; + } + + if (!string.IsNullOrWhiteSpace(cardText)) + { + card.Text = cardText; + } + + // Return the card as an attachment + return card.ToAttachment(); + } } } diff --git a/tests/Microsoft.Bot.Builder.AI.QnA.Tests/QnAMakerTests.cs b/tests/Microsoft.Bot.Builder.AI.QnA.Tests/QnAMakerTests.cs index 1f3758f53..01e9c27c4 100644 --- a/tests/Microsoft.Bot.Builder.AI.QnA.Tests/QnAMakerTests.cs +++ b/tests/Microsoft.Bot.Builder.AI.QnA.Tests/QnAMakerTests.cs @@ -189,6 +189,145 @@ namespace Microsoft.Bot.Builder.AI.Tests .StartTestAsync(); } + /// + /// The QnAMakerAction_ActiveLearningDialogBase_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards. + /// + /// The . + public AdaptiveDialog QnAMakerAction_ActiveLearningDialogBase_AdaptiveCard() + { + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When(HttpMethod.Post, GetTrainRequestUrl()) + .Respond(HttpStatusCode.NoContent, "application/json", "{ }"); + mockHttp.When(HttpMethod.Post, GetRequestUrl()).WithContent("{\"question\":\"Q12\",\"top\":3,\"strictFilters\":[],\"scoreThreshold\":30.0,\"context\":{\"previousQnAId\":0,\"previousUserQuery\":\"\"},\"qnaId\":0,\"isTest\":false,\"rankerType\":\"Default\",\"StrictFiltersCompoundOperationType\":0}") + .Respond("application/json", GetResponse("QnaMaker_ReturnsAnswer_WhenNoAnswerFoundInKb.json")); + mockHttp.When(HttpMethod.Post, GetRequestUrl()).WithContent("{\"question\":\"Q11\",\"top\":3,\"strictFilters\":[],\"scoreThreshold\":30.0,\"context\":{\"previousQnAId\":0,\"previousUserQuery\":\"\"},\"qnaId\":0,\"isTest\":false,\"rankerType\":\"Default\",\"StrictFiltersCompoundOperationType\":0}") + .Respond("application/json", GetResponse("QnaMaker_TopNAnswer.json")); + return CreateQnAMakerActionDialog_AdaptiveCard(mockHttp); + } + + /// + /// The QnAMakerAction_ActiveLearningDialog_WithProperResponse_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards. + /// + /// The . + [Fact] + public async Task QnAMakerAction_ActiveLearningDialog_WithProperResponse_AdaptiveCard() + { + var rootDialog = QnAMakerAction_ActiveLearningDialogBase_AdaptiveCard(); + + var suggestionList = new List { "Q1", "Q2", "Q3" }; + var suggestionActivity = QnACardBuilder.GetSuggestionsCard(suggestionList, "Did you mean:", "None of the above."); + var qnAMakerCardEqualityComparer = new QnAMakerCardEqualityComparer(); + + await CreateFlow(rootDialog, "QnAMakerAction_ActiveLearningDialog_WithProperResponse") + .Send("Q11") + .AssertReply(suggestionActivity, equalityComparer: qnAMakerCardEqualityComparer) + .Send("Q1") + .AssertReply("A1") + .StartTestAsync(); + } + + /// + /// The QnAMakerAction_ActiveLearningDialog_WithNoResponse_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards. + /// + /// The . + [Fact] + public async Task QnAMakerAction_ActiveLearningDialog_WithNoResponse_AdaptiveCard() + { + var rootDialog = QnAMakerAction_ActiveLearningDialogBase_AdaptiveCard(); + + const string noAnswerActivity = "No match found, please ask another question."; + + var suggestionList = new List { "Q1", "Q2", "Q3" }; + var suggestionActivity = QnACardBuilder.GetSuggestionsCard(suggestionList, "Did you mean:", "None of the above."); + var qnAMakerCardEqualityComparer = new QnAMakerCardEqualityComparer(); + + await CreateFlow(rootDialog, "QnAMakerAction_ActiveLearningDialog_WithNoResponse") + .Send("Q11") + .AssertReply(suggestionActivity, equalityComparer: qnAMakerCardEqualityComparer) + .Send("Q12") + .AssertReply(noAnswerActivity) + .StartTestAsync(); + } + + /// + /// The QnAMakerAction_ActiveLearningDialog_WithNoneOfAboveQuery_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards. + /// + /// The . + [Fact] + public async Task QnAMakerAction_ActiveLearningDialog_WithNoneOfAboveQuery_AdaptiveCard() + { + var rootDialog = QnAMakerAction_ActiveLearningDialogBase_AdaptiveCard(); + + var suggestionList = new List { "Q1", "Q2", "Q3" }; + var suggestionActivity = QnACardBuilder.GetSuggestionsCard(suggestionList, "Did you mean:", "None of the above."); + var qnAMakerCardEqualityComparer = new QnAMakerCardEqualityComparer(); + + await CreateFlow(rootDialog, "QnAMakerAction_ActiveLearningDialog_WithNoneOfAboveQuery") + .Send("Q11") + .AssertReply(suggestionActivity, equalityComparer: qnAMakerCardEqualityComparer) + .Send("None of the above.") + .AssertReply("Thanks for the feedback.") + .StartTestAsync(); + } + + /// + /// The QnAMakerAction_MultiTurnDialogBase_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards. + /// + /// The . + public AdaptiveDialog QnAMakerAction_MultiTurnDialogBase_AdaptiveCard() + { + var mockHttp = new MockHttpMessageHandler(); + + mockHttp.When(HttpMethod.Post, GetRequestUrl()).WithContent("{\"question\":\"I have issues related to KB\",\"top\":3,\"strictFilters\":[],\"scoreThreshold\":30.0,\"context\":{\"previousQnAId\":0,\"previousUserQuery\":\"\"},\"qnaId\":0,\"isTest\":false,\"rankerType\":\"Default\",\"StrictFiltersCompoundOperationType\":0}") + .Respond("application/json", GetResponse("QnaMaker_ReturnAnswer_withPrompts.json")); + mockHttp.When(HttpMethod.Post, GetRequestUrl()).WithContent("{\"question\":\"Accidently deleted KB\",\"top\":3,\"strictFilters\":[],\"scoreThreshold\":30.0,\"context\":{\"previousQnAId\":27,\"previousUserQuery\":\"\"},\"qnaId\":1,\"isTest\":false,\"rankerType\":\"Default\",\"StrictFiltersCompoundOperationType\":0}") + .Respond("application/json", GetResponse("QnaMaker_ReturnAnswer_MultiTurnLevel1.json")); + + return CreateQnAMakerActionDialog_AdaptiveCard(mockHttp); + } + + /// + /// The QnAMakerAction_MultiTurnDialogBase_WithAnswer_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards. + /// + /// The . + [Fact] + public async Task QnAMakerAction_MultiTurnDialogBase_WithAnswer_AdaptiveCard() + { + var rootDialog = QnAMakerAction_MultiTurnDialogBase_AdaptiveCard(); + + var response = JsonConvert.DeserializeObject(File.ReadAllText(GetFilePath("QnaMaker_ReturnAnswer_withPrompts.json"))); + var promptsActivity = QnACardBuilder.GetQnAPromptsCard(response.Answers[0]); + var qnAMakerCardEqualityComparer = new QnAMakerCardEqualityComparer(); + + await CreateFlow(rootDialog, nameof(QnAMakerAction_MultiTurnDialogBase_WithAnswer_AdaptiveCard)) + .Send("I have issues related to KB") + .AssertReply(promptsActivity, equalityComparer: qnAMakerCardEqualityComparer) + .Send("Accidently deleted KB") + .AssertReply("All deletes are permanent, including question and answer pairs, files, URLs, custom questions and answers, knowledge bases, or Azure resources. Make sure you export your knowledge base from the Settings**page before deleting any part of your knowledge base.") + .StartTestAsync(); + } + + /// + /// The QnAMakerAction_MultiTurnDialogBase_WithNoAnswer_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards. + /// + /// The . + [Fact] + public async Task QnAMakerAction_MultiTurnDialogBase_WithNoAnswer_AdaptiveCard() + { + var rootDialog = QnAMakerAction_MultiTurnDialogBase_AdaptiveCard(); + + var response = JsonConvert.DeserializeObject(File.ReadAllText(GetFilePath("QnaMaker_ReturnAnswer_withPrompts.json"))); + var promptsActivity = QnACardBuilder.GetQnAPromptsCard(response.Answers[0]); + var qnAMakerCardEqualityComparer = new QnAMakerCardEqualityComparer(); + + await CreateFlow(rootDialog, nameof(QnAMakerAction_MultiTurnDialogBase_WithNoAnswer_AdaptiveCard)) + .Send("I have issues related to KB") + .AssertReply(promptsActivity, equalityComparer: qnAMakerCardEqualityComparer) + .Send("None of the above.") + .AssertReply("Thanks for the feedback.") + .StartTestAsync(); + } + /// /// The QnaMaker_TraceActivity. /// @@ -1972,6 +2111,72 @@ namespace Microsoft.Bot.Builder.AI.Tests return rootDialog; } + /// + /// The CreateQnAMakerActionDialog_AdaptiveCard. Tests Adaptive Cards in QnAMakerDialog. + /// + /// The mockHttp. + /// The . + private AdaptiveDialog CreateQnAMakerActionDialog_AdaptiveCard(MockHttpMessageHandler mockHttp) + { + var client = new HttpClient(mockHttp); + + var noAnswerActivity = new ActivityTemplate("No match found, please ask another question."); + const string host = "https://dummy-hostname.azurewebsites.net/qnamaker"; + const string knowledgeBaseId = "dummy-id"; + const string endpointKey = "dummy-key"; + const string activeLearningCardTitle = "QnAMaker Active Learning"; + + var outerDialog = new AdaptiveDialog("outer") + { + AutoEndDialog = false, + Triggers = new List + { + new OnBeginDialog + { + Actions = new List + { + new QnAMakerDialog + { + KnowledgeBaseId = knowledgeBaseId, + HostName = host, + EndpointKey = endpointKey, + HttpClient = client, + NoAnswer = noAnswerActivity, + ActiveLearningCardTitle = activeLearningCardTitle, + CardNoMatchText = "None of the above.", + UseTeamsAdaptiveCard = true + } + } + } + } + }; + + var rootDialog = new AdaptiveDialog("root") + { + Triggers = new List + { + new OnBeginDialog + { + Actions = new List + { + new BeginDialog(outerDialog.Id) + } + }, + new OnDialogEvent + { + Event = "UnhandledUnknownIntent", + Actions = new List + { + new EditArray(), + new SendActivity("magenta") + } + } + } + }; + rootDialog.Dialogs.Add(outerDialog); + return rootDialog; + } + /// /// The GetV2LegacyRequestUrl. ///