Add support for Teams Adaptive cards in QnA Dialog (#6625)

* Update QnaMakerDialog.cs to add new bool parameter

* Add adaptive card implementation

* Add new parameter to GetQnADefaulTResponse calls

* Add comments and check for empty text

* Clean up code, update comments, update method names

* Address requested changes

* Add versions of tests for QnAMakerDialog that use Adaptive Card implementation

Tests passing on branch code.

---------

Co-authored-by: Anish Prasad <v-aniprasad@microsoft.com>
This commit is contained in:
Anish Prasad 2023-05-16 09:21:49 -07:00 коммит произвёл GitHub
Родитель 11597ca9af
Коммит 68e0c421d2
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
3 изменённых файлов: 337 добавлений и 25 удалений

Просмотреть файл

@ -102,6 +102,7 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs
/// of the source file that contains the caller.</param>
/// <param name="sourceLineNumber">The line number, for debugging. Defaults to the line number
/// in the source file at which the method is called.</param>
/// <param name="useTeamsAdaptiveCard"> Boolean value to determine whether an Adaptive card formatted for Teams should be used for responses.</param>
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.</param>
/// <param name="sourceLineNumber">The line number, for debugging. Defaults to the line number
/// in the source file at which the method is called.</param>
/// <param name="useTeamsAdaptiveCard"> Boolean value to determine whether an Adaptive card formatted for Teams should be used for responses.</param>
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;
/// <summary>
/// Gets or sets a value indicating whether the dialog response should use a MS Teams formatted Adaptive Card instead of a Hero Card.
/// </summary>
/// <value>
/// True/False, defaults to False.
/// </value>
[JsonProperty("useTeamsAdaptiveCard")]
public BoolExpression UseTeamsAdaptiveCard { get; set; } = false;
/// <summary>
/// Gets or sets QnA Service type to query either QnAMaker or Custom Question Answering Knowledge Base.
/// </summary>
@ -614,7 +629,7 @@ namespace Microsoft.Bot.Builder.AI.QnA.Dialogs
var response = (List<QueryResult>)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);

Просмотреть файл

@ -94,8 +94,9 @@ namespace Microsoft.Bot.Builder.AI.QnA
/// </summary>
/// <param name="result">Result to be dispalyed as prompts.</param>
/// <param name="displayPreciseAnswerOnly">Choice to render precise answer.</param>
/// <param name="useTeamsAdaptiveCard">Choose whether to use a Teams-formatted Adaptive card.</param>
/// <returns>IMessageActivity.</returns>
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="cardText">String of text to be added to the card.</param>
/// <param name="buttonList">List of CardAction representing buttons to be added to the card.</param>
/// <returns>Attachment.</returns>
private static Attachment CreateAdaptiveCardAttachment(string cardText, List<CardAction> 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<string, object>
{
{ "type", "Action.Submit" },
{ "title", button.Title },
{
"data",
new Dictionary<string, object>
{
{
"msteams",
new Dictionary<string, object>
{
{ "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<string, object>
{
{ "$schema", "http://adaptivecards.io/schemas/adaptive-card.json" },
{ "type", "AdaptiveCard" },
{ "version", "1.3" },
{
"msteams",
new Dictionary<string, string>
{
{ "width", "full" },
{ "height", "full" }
}
},
{
"body",
new Dictionary<string, string>[]
{
new Dictionary<string, string>
{
{ "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;
}
/// <summary>
/// Get a Hero Card as Attachment to be returned in the QnA response.
/// </summary>
/// <param name="cardText">string of text to be added to the card.</param>
/// <param name="buttonList">List of CardAction representing buttons to be added to the card.</param>
/// <returns>Attachment.</returns>
private static Attachment CreateHeroCardAttachment(string cardText, List<CardAction> 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();
}
}
}

Просмотреть файл

@ -189,6 +189,145 @@ namespace Microsoft.Bot.Builder.AI.Tests
.StartTestAsync();
}
/// <summary>
/// The QnAMakerAction_ActiveLearningDialogBase_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards.
/// </summary>
/// <returns>The <see cref="AdaptiveDialog"/>.</returns>
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);
}
/// <summary>
/// The QnAMakerAction_ActiveLearningDialog_WithProperResponse_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards.
/// </summary>
/// <returns>The <see cref="Task"/>.</returns>
[Fact]
public async Task QnAMakerAction_ActiveLearningDialog_WithProperResponse_AdaptiveCard()
{
var rootDialog = QnAMakerAction_ActiveLearningDialogBase_AdaptiveCard();
var suggestionList = new List<string> { "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();
}
/// <summary>
/// The QnAMakerAction_ActiveLearningDialog_WithNoResponse_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards.
/// </summary>
/// <returns>The <see cref="Task"/>.</returns>
[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<string> { "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();
}
/// <summary>
/// The QnAMakerAction_ActiveLearningDialog_WithNoneOfAboveQuery_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards.
/// </summary>
/// <returns>The <see cref="Task"/>.</returns>
[Fact]
public async Task QnAMakerAction_ActiveLearningDialog_WithNoneOfAboveQuery_AdaptiveCard()
{
var rootDialog = QnAMakerAction_ActiveLearningDialogBase_AdaptiveCard();
var suggestionList = new List<string> { "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();
}
/// <summary>
/// The QnAMakerAction_MultiTurnDialogBase_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards.
/// </summary>
/// <returns>The <see cref="AdaptiveDialog"/>.</returns>
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);
}
/// <summary>
/// The QnAMakerAction_MultiTurnDialogBase_WithAnswer_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards.
/// </summary>
/// <returns>The <see cref="Task"/>.</returns>
[Fact]
public async Task QnAMakerAction_MultiTurnDialogBase_WithAnswer_AdaptiveCard()
{
var rootDialog = QnAMakerAction_MultiTurnDialogBase_AdaptiveCard();
var response = JsonConvert.DeserializeObject<QueryResults>(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();
}
/// <summary>
/// The QnAMakerAction_MultiTurnDialogBase_WithNoAnswer_AdaptiveCard. Tests QnAMakerDialog with Adaptive Cards.
/// </summary>
/// <returns>The <see cref="Task"/>.</returns>
[Fact]
public async Task QnAMakerAction_MultiTurnDialogBase_WithNoAnswer_AdaptiveCard()
{
var rootDialog = QnAMakerAction_MultiTurnDialogBase_AdaptiveCard();
var response = JsonConvert.DeserializeObject<QueryResults>(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();
}
/// <summary>
/// The QnaMaker_TraceActivity.
/// </summary>
@ -1972,6 +2111,72 @@ namespace Microsoft.Bot.Builder.AI.Tests
return rootDialog;
}
/// <summary>
/// The CreateQnAMakerActionDialog_AdaptiveCard. Tests Adaptive Cards in QnAMakerDialog.
/// </summary>
/// <param name="mockHttp">The mockHttp<see cref="MockHttpMessageHandler"/>.</param>
/// <returns>The <see cref="AdaptiveDialog"/>.</returns>
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<OnCondition>
{
new OnBeginDialog
{
Actions = new List<Dialog>
{
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<OnCondition>
{
new OnBeginDialog
{
Actions = new List<Dialog>
{
new BeginDialog(outerDialog.Id)
}
},
new OnDialogEvent
{
Event = "UnhandledUnknownIntent",
Actions = new List<Dialog>
{
new EditArray(),
new SendActivity("magenta")
}
}
}
};
rootDialog.Dialogs.Add(outerDialog);
return rootDialog;
}
/// <summary>
/// The GetV2LegacyRequestUrl.
/// </summary>