diff --git a/etc/bot-checkstyle.xml b/etc/bot-checkstyle.xml index 337417b9e..da9b3f275 100644 --- a/etc/bot-checkstyle.xml +++ b/etc/bot-checkstyle.xml @@ -184,6 +184,8 @@ + + diff --git a/libraries/bot-ai-qna/README.md b/libraries/bot-ai-qna/README.md new file mode 100644 index 000000000..72f1506a9 --- /dev/null +++ b/libraries/bot-ai-qna/README.md @@ -0,0 +1,14 @@ + +# Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/libraries/bot-ai-qna/pom.xml b/libraries/bot-ai-qna/pom.xml index 133a60559..4de263b57 100644 --- a/libraries/bot-ai-qna/pom.xml +++ b/libraries/bot-ai-qna/pom.xml @@ -49,15 +49,46 @@ junit junit - org.slf4j slf4j-api - com.microsoft.bot bot-builder + ${project.version} + + + com.microsoft.bot + bot-dialogs + + + com.microsoft.bot + bot-builder + ${project.version} + test-jar + test + + + com.microsoft.bot + bot-integration-spring + 4.6.0-preview8 + compile + + + com.squareup.okhttp3 + okhttp + 4.9.0 + + + com.squareup.okhttp3 + mockwebserver + 4.9.0 + test + + + org.mockito + mockito-core diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/JoinOperator.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/JoinOperator.java new file mode 100644 index 000000000..cbba1cc6c --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/JoinOperator.java @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +/** + * Join Operator for Strict Filters. + */ +public enum JoinOperator { + /** + * Default Join Operator, AND. + */ + AND, + + /** + * Join Operator, OR. + */ + OR +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnADialogResponseOptions.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnADialogResponseOptions.java new file mode 100644 index 000000000..be9bcf3ff --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnADialogResponseOptions.java @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import com.microsoft.bot.schema.Activity; + +/** + * QnA dialog response options class. + */ +public class QnADialogResponseOptions { + private String activeLearningCardTitle; + private String cardNoMatchText; + private Activity noAnswer; + private Activity cardNoMatchResponse; + + /** + * Gets the active learning card title. + * + * @return The active learning card title + */ + public String getActiveLearningCardTitle() { + return activeLearningCardTitle; + } + + /** + * Sets the active learning card title. + * + * @param withActiveLearningCardTitle The active learning card title. + */ + public void setActiveLearningCardTitle(String withActiveLearningCardTitle) { + this.activeLearningCardTitle = withActiveLearningCardTitle; + } + + /** + * Gets the card no match text. + * + * @return The card no match text. + */ + public String getCardNoMatchText() { + return cardNoMatchText; + } + + /** + * Sets the card no match text. + * + * @param withCardNoMatchText The card no match text. + */ + public void setCardNoMatchText(String withCardNoMatchText) { + this.cardNoMatchText = withCardNoMatchText; + } + + /** + * Gets the no answer activity. + * + * @return The no answer activity. + */ + public Activity getNoAnswer() { + return noAnswer; + } + + /** + * Sets the no answer activity. + * + * @param withNoAnswer The no answer activity. + */ + public void setNoAnswer(Activity withNoAnswer) { + this.noAnswer = withNoAnswer; + } + + /** + * Gets the card no match response. + * + * @return The card no match response. + */ + public Activity getCardNoMatchResponse() { + return cardNoMatchResponse; + } + + /** + * Sets the card no match response. + * + * @param withCardNoMatchResponse The card no match response. + */ + public void setCardNoMatchResponse(Activity withCardNoMatchResponse) { + this.cardNoMatchResponse = withCardNoMatchResponse; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMaker.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMaker.java new file mode 100644 index 000000000..b6b5d3d1d --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMaker.java @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import com.google.common.collect.LinkedListMultimap; +import com.google.common.collect.Multimap; +import com.microsoft.bot.ai.qna.models.FeedbackRecords; +import com.microsoft.bot.ai.qna.models.QueryResult; +import com.microsoft.bot.ai.qna.models.QueryResults; +import com.microsoft.bot.ai.qna.utils.ActiveLearningUtils; +import com.microsoft.bot.ai.qna.utils.GenerateAnswerUtils; +import com.microsoft.bot.ai.qna.utils.QnATelemetryConstants; +import com.microsoft.bot.ai.qna.utils.TrainUtils; + +import com.microsoft.bot.builder.BotTelemetryClient; +import com.microsoft.bot.builder.NullBotTelemetryClient; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.restclient.serializer.JacksonAdapter; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.Pair; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.google.common.base.Strings; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.LoggerFactory; + +/** + * Provides access to a QnA Maker knowledge base. + */ +public class QnAMaker implements QnAMakerClient, TelemetryQnAMaker { + + private QnAMakerEndpoint endpoint; + + private GenerateAnswerUtils generateAnswerHelper; + private TrainUtils activeLearningTrainHelper; + private Boolean logPersonalInformation; + @JsonIgnore + private BotTelemetryClient telemetryClient; + + /** + * The name of the QnAMaker class. + */ + public static final String QNA_MAKER_NAME = "QnAMaker"; + /** + * The type used when logging QnA Maker trace. + */ + public static final String QNA_MAKER_TRACE_TYPE = "https://www.qnamaker.ai/schemas/trace"; + /** + * The label used when logging QnA Maker trace. + */ + public static final String QNA_MAKER_TRACE_LABEL = "QnAMaker Trace"; + + /** + * Initializes a new instance of the QnAMaker class. + * + * @param withEndpoint The endpoint of the knowledge base to + * query. + * @param options The options for the QnA Maker knowledge + * base. + * @param withTelemetryClient The IBotTelemetryClient used for logging + * telemetry events. + * @param withLogPersonalInformation Set to true to include personally + * identifiable information in telemetry + * events. + */ + public QnAMaker(QnAMakerEndpoint withEndpoint, QnAMakerOptions options, + BotTelemetryClient withTelemetryClient, Boolean withLogPersonalInformation) { + if (withLogPersonalInformation == null) { + withLogPersonalInformation = false; + } + + if (withEndpoint == null) { + throw new IllegalArgumentException("endpoint"); + } + this.endpoint = withEndpoint; + + if (Strings.isNullOrEmpty(this.endpoint.getKnowledgeBaseId())) { + throw new IllegalArgumentException("knowledgeBaseId"); + } + + if (Strings.isNullOrEmpty(this.endpoint.getHost())) { + throw new IllegalArgumentException("host"); + } + + if (Strings.isNullOrEmpty(this.endpoint.getEndpointKey())) { + throw new IllegalArgumentException("endpointKey"); + } + + if (this.endpoint.getHost().endsWith("v2.0") || this.endpoint.getHost().endsWith("v3.0")) { + throw new UnsupportedOperationException( + "v2.0 and v3.0 of QnA Maker service" + " is no longer supported in the QnA Maker."); + } + + this.telemetryClient = withTelemetryClient != null ? withTelemetryClient : new NullBotTelemetryClient(); + this.logPersonalInformation = withLogPersonalInformation; + + this.generateAnswerHelper = new GenerateAnswerUtils(this.endpoint, options); + this.activeLearningTrainHelper = new TrainUtils(this.endpoint); + } + + /** + * Initializes a new instance of the {@link QnAMaker} class. + * + * @param withEndpoint The endpoint of the knowledge base to query. + * @param options The options for the QnA Maker knowledge base. + */ + public QnAMaker(QnAMakerEndpoint withEndpoint, @Nullable QnAMakerOptions options) { + this(withEndpoint, options, null, null); + } + + /** + * Gets a value indicating whether determines whether to log personal + * information that came from the user. + * + * @return If true, will log personal information into the + * IBotTelemetryClient.TrackEvent method; otherwise the properties will + * be filtered. + */ + public Boolean getLogPersonalInformation() { + return this.logPersonalInformation; + } + + /** + * Gets the currently configured {@link BotTelemetryClient}. + * + * @return {@link BotTelemetryClient} being used to log events. + */ + public BotTelemetryClient getTelemetryClient() { + return this.telemetryClient; + } + + /** + * Generates an answer from the knowledge base. + * + * @param turnContext The Turn Context that contains the user question to be + * queried against your knowledge base. + * @param options The options for the QnA Maker knowledge base. If null, + * constructor option is used for this instance. + * @return A list of answers for the user query, sorted in decreasing order of + * ranking score. + */ + public CompletableFuture getAnswers(TurnContext turnContext, @Nullable QnAMakerOptions options) { + return this.getAnswers(turnContext, options, null, null); + } + + /** + * Generates an answer from the knowledge base. + * + * @param turnContext The Turn Context that contains the user question + * to be queried against your knowledge base. + * @param options The options for the QnA Maker knowledge base. If + * null, constructor option is used for this + * instance. + * @param telemetryProperties Additional properties to be logged to telemetry + * with the QnaMessage event. + * @param telemetryMetrics Additional metrics to be logged to telemetry with + * the QnaMessage event. + * @return A list of answers for the user query, sorted in decreasing order of + * ranking score. + */ + public CompletableFuture getAnswers(TurnContext turnContext, QnAMakerOptions options, + Map telemetryProperties, @Nullable Map telemetryMetrics) { + return this.getAnswersRaw(turnContext, options, telemetryProperties, telemetryMetrics) + .thenApply(result -> result.getAnswers()); + } + + /** + * Generates an answer from the knowledge base. + * + * @param turnContext The Turn Context that contains the user question + * to be queried against your knowledge base. + * @param options The options for the QnA Maker knowledge base. If + * null, constructor option is used for this + * instance. + * @param telemetryProperties Additional properties to be logged to telemetry + * with the QnaMessage event. + * @param telemetryMetrics Additional metrics to be logged to telemetry with + * the QnaMessage event. + * @return A list of answers for the user query, sorted in decreasing order of + * ranking score. + */ + public CompletableFuture getAnswersRaw(TurnContext turnContext, QnAMakerOptions options, + @Nullable Map telemetryProperties, @Nullable Map telemetryMetrics) { + if (turnContext == null) { + throw new IllegalArgumentException("turnContext"); + } + if (turnContext.getActivity() == null) { + throw new IllegalArgumentException( + String.format("The %1$s property for %2$s can't be null.", "Activity", "turnContext")); + } + Activity messageActivity = turnContext.getActivity(); + if (messageActivity == null || !messageActivity.isType(ActivityTypes.MESSAGE)) { + throw new IllegalArgumentException("Activity type is not a message"); + } + + if (Strings.isNullOrEmpty(turnContext.getActivity().getText())) { + throw new IllegalArgumentException("Null or empty text"); + } + + return this.generateAnswerHelper.getAnswersRaw(turnContext, messageActivity, options).thenCompose(result -> { + try { + this.onQnaResults(result.getAnswers(), turnContext, telemetryProperties, telemetryMetrics); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMaker.class).error("getAnswersRaw"); + } + return CompletableFuture.completedFuture(result); + }); + } + + /** + * Filters the ambiguous question for active learning. + * + * @param queryResult User query output. + * @return Filtered array of ambiguous question. + */ + public QueryResult[] getLowScoreVariation(QueryResult[] queryResult) { + List queryResults = ActiveLearningUtils.getLowScoreVariation(Arrays.asList(queryResult)); + return queryResults.toArray(new QueryResult[queryResults.size()]); + } + + /** + * Send feedback to the knowledge base. + * + * @param feedbackRecords Feedback records. + * @return Representing the asynchronous operation. + * @throws IOException Throws an IOException if there is any. + */ + public CompletableFuture callTrain(FeedbackRecords feedbackRecords) throws IOException { + return this.activeLearningTrainHelper.callTrain(feedbackRecords); + } + + /** + * Executed when a result is returned from QnA Maker. + * + * @param queryResults An array of {@link QueryResult} + * @param turnContext The {@link TurnContext} + * @param telemetryProperties Additional properties to be logged to telemetry + * with the LuisResult event. + * @param telemetryMetrics Additional metrics to be logged to telemetry with + * the LuisResult event. + * @return A Task representing the work to be executed. + * @throws IOException Throws an IOException if there is any. + */ + protected CompletableFuture onQnaResults(QueryResult[] queryResults, TurnContext turnContext, + @Nullable Map telemetryProperties, @Nullable Map telemetryMetrics) + throws IOException { + return fillQnAEvent(queryResults, turnContext, telemetryProperties, telemetryMetrics).thenAccept(eventData -> { + // Track the event + this.telemetryClient.trackEvent(QnATelemetryConstants.QNA_MSG_EVENT, eventData.getLeft(), + eventData.getRight()); + }); + } + + /** + * Fills the event properties and metrics for the QnaMessage event for + * telemetry. These properties are logged when the QnA GetAnswers method is + * called. + * + * @param queryResults QnA service results. + * @param turnContext Context object containing information for a single + * turn of conversation with a user. + * @param telemetryProperties Properties to add/override for the event. + * @param telemetryMetrics Metrics to add/override for the event. + * @return A tuple of Properties and Metrics that will be sent to the + * IBotTelemetryClient. TrackEvent method for the QnAMessage event. The + * properties and metrics returned the standard properties logged with + * any properties passed from the GetAnswersAsync method. + * @throws IOException Throws an IOException if there is any. + */ + protected CompletableFuture, Map>> fillQnAEvent(QueryResult[] queryResults, + TurnContext turnContext, @Nullable Map telemetryProperties, + @Nullable Map telemetryMetrics) throws IOException { + Map properties = new HashMap(); + Map metrics = new HashMap(); + + properties.put(QnATelemetryConstants.KNOWLEDGE_BASE_ID_PROPERTY, this.endpoint.getKnowledgeBaseId()); + + String text = turnContext.getActivity().getText(); + String userName = turnContext.getActivity().getFrom() != null + ? turnContext.getActivity().getFrom().getName() : null; + + // Use the LogPersonalInformation flag to toggle logging PII data, text and user + // name are common examples + if (this.logPersonalInformation) { + if (!StringUtils.isBlank(text)) { + properties.put(QnATelemetryConstants.QUESTION_PROPERTY, text); + } + + if (!StringUtils.isBlank(userName)) { + properties.put(QnATelemetryConstants.USERNAME_PROPERTY, userName); + } + } + + // Fill in QnA Results (found or not) + if (queryResults.length > 0) { + JacksonAdapter jacksonAdapter = new JacksonAdapter(); + QueryResult queryResult = queryResults[0]; + properties.put(QnATelemetryConstants.MATCHED_QUESTION_PROPERTY, + jacksonAdapter.serialize(queryResult.getQuestions())); + properties.put(QnATelemetryConstants.QUESTION_ID_PROPERTY, queryResult.getId().toString()); + properties.put(QnATelemetryConstants.ANSWER_PROPERTY, queryResult.getAnswer()); + metrics.put(QnATelemetryConstants.SCORE_PROPERTY, queryResult.getScore().doubleValue()); + properties.put(QnATelemetryConstants.ARTICLE_FOUND_PROPERTY, "true"); + } else { + properties.put(QnATelemetryConstants.MATCHED_QUESTION_PROPERTY, "No Qna Question matched"); + properties.put(QnATelemetryConstants.QUESTION_ID_PROPERTY, "No QnA Question Id matched"); + properties.put(QnATelemetryConstants.ANSWER_PROPERTY, "No Qna Answer matched"); + properties.put(QnATelemetryConstants.ARTICLE_FOUND_PROPERTY, "false"); + } + + // Additional Properties can override "stock" properties. + if (telemetryProperties != null) { + Multimap multiMapTelemetryProperties = LinkedListMultimap.create(); + for (Entry entry: telemetryProperties.entrySet()) { + multiMapTelemetryProperties.put(entry.getKey(), entry.getValue()); + } + for (Entry entry: properties.entrySet()) { + multiMapTelemetryProperties.put(entry.getKey(), entry.getValue()); + } + for (Entry> entry: multiMapTelemetryProperties.asMap().entrySet()) { + telemetryProperties.put(entry.getKey(), entry.getValue().iterator().next()); + } + } + + // Additional Metrics can override "stock" metrics. + if (telemetryMetrics != null) { + Multimap multiMapTelemetryMetrics = LinkedListMultimap.create(); + for (Entry entry: telemetryMetrics.entrySet()) { + multiMapTelemetryMetrics.put(entry.getKey(), entry.getValue()); + } + for (Entry entry: metrics.entrySet()) { + multiMapTelemetryMetrics.put(entry.getKey(), entry.getValue()); + } + for (Entry> entry: multiMapTelemetryMetrics.asMap().entrySet()) { + telemetryMetrics.put(entry.getKey(), entry.getValue().iterator().next()); + } + } + + Map telemetryPropertiesResult = telemetryProperties != null ? telemetryProperties : properties; + Map telemetryMetricsResult = telemetryMetrics != null ? telemetryMetrics : metrics; + return CompletableFuture.completedFuture(new Pair<>(telemetryPropertiesResult, telemetryMetricsResult)); + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerClient.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerClient.java new file mode 100644 index 000000000..55da459d9 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerClient.java @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import javax.annotation.Nullable; + +import com.microsoft.bot.ai.qna.models.FeedbackRecords; +import com.microsoft.bot.ai.qna.models.QueryResult; +import com.microsoft.bot.ai.qna.models.QueryResults; +import com.microsoft.bot.builder.TurnContext; + +/** + * Client to access a QnA Maker knowledge base. + */ +public interface QnAMakerClient { + /** + * Generates an answer from the knowledge base. + * + * @param turnContext The Turn Context that contains the user question + * to be queried against your knowledge base. + * @param options The options for the QnA Maker knowledge base. If + * null, constructor option is used for this + * instance. + * @param telemetryProperties Additional properties to be logged to telemetry + * with the QnaMessage event. + * @param telemetryMetrics Additional metrics to be logged to telemetry with + * the QnaMessage event. + * @return A list of answers for the user query, sorted in decreasing order of + * ranking score. + */ + CompletableFuture getAnswers(TurnContext turnContext, QnAMakerOptions options, + Map telemetryProperties, @Nullable Map telemetryMetrics); + + /** + * Generates an answer from the knowledge base. + * + * @param turnContext The Turn Context that contains the user question + * to be queried against your knowledge base. + * @param options The options for the QnA Maker knowledge base. If + * null, constructor option is used for this + * instance. + * @param telemetryProperties Additional properties to be logged to telemetry + * with the QnaMessage event. + * @param telemetryMetrics Additional metrics to be logged to telemetry with + * the QnaMessage event. + * @return A list of answers for the user query, sorted in decreasing order of + * ranking score. + */ + CompletableFuture getAnswersRaw(TurnContext turnContext, QnAMakerOptions options, + @Nullable Map telemetryProperties, @Nullable Map telemetryMetrics); + + /** + * Filters the ambiguous question for active learning. + * + * @param queryResults User query output. + * @return Filtered array of ambiguous question. + */ + QueryResult[] getLowScoreVariation(QueryResult[] queryResults); + + /** + * Send feedback to the knowledge base. + * + * @param feedbackRecords Feedback records. + * @return A Task representing the asynchronous operation. + * @throws IOException Throws an IOException if there is any. + */ + CompletableFuture callTrain(FeedbackRecords feedbackRecords) throws IOException; +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerEndpoint.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerEndpoint.java new file mode 100644 index 000000000..c185124b7 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerEndpoint.java @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Defines an endpoint used to connect to a QnA Maker Knowledge base. + */ +public class QnAMakerEndpoint { + @JsonProperty("knowledgeBaseId") + private String knowledgeBaseId; + + @JsonProperty("endpointKey") + private String endpointKey; + + @JsonProperty("host") + private String host; + + /** + * Gets the knowledge base ID. + * + * @return The knowledge base ID. + */ + public String getKnowledgeBaseId() { + return knowledgeBaseId; + } + + /** + * Sets the knowledge base ID. + * + * @param withKnowledgeBaseId The knowledge base ID. + */ + public void setKnowledgeBaseId(String withKnowledgeBaseId) { + this.knowledgeBaseId = withKnowledgeBaseId; + } + + /** + * Gets the endpoint key for the knowledge base. + * + * @return The endpoint key for the knowledge base. + */ + public String getEndpointKey() { + return endpointKey; + } + + /** + * Sets the endpoint key for the knowledge base. + * + * @param withEndpointKey The endpoint key for the knowledge base. + */ + public void setEndpointKey(String withEndpointKey) { + this.endpointKey = withEndpointKey; + } + + /** + * Gets the host path. For example + * "https://westus.api.cognitive.microsoft.com/qnamaker/v2.0". + * + * @return The host path. + */ + public String getHost() { + return host; + } + + /** + * Sets the host path. For example + * "https://westus.api.cognitive.microsoft.com/qnamaker/v2.0". + * + * @param withHost The host path. + */ + public void setHost(String withHost) { + this.host = withHost; + } + + /** + * Initializes a new instance of the {@link QnAMakerEndpoint} class. + */ + public QnAMakerEndpoint() { + + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerOptions.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerOptions.java new file mode 100644 index 000000000..d1d91e3d7 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerOptions.java @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.microsoft.bot.ai.qna.models.Metadata; +import com.microsoft.bot.ai.qna.models.QnARequestContext; + +/** + * Defines options for the QnA Maker knowledge base. + */ +public class QnAMakerOptions { + @JsonProperty("scoreThreshold") + private Float scoreThreshold; + + @JsonProperty("timeout") + private Double timeout = 0d; + + @JsonProperty("top") + private Integer top = 0; + + @JsonProperty("context") + private QnARequestContext context; + + @JsonProperty("qnAId") + private Integer qnAId; + + @JsonProperty("strictFilters") + private Metadata[] strictFilters; + + @Deprecated + @JsonIgnore + private Metadata[] metadataBoost; + + @JsonProperty("isTest") + private Boolean isTest; + + @JsonProperty("rankerType") + private String rankerType; + + @JsonProperty("strictFiltersJoinOperator") + private JoinOperator strictFiltersJoinOperator; + + private static final Float SCORE_THRESHOLD = 0.3f; + + /** + * Gets the minimum score threshold, used to filter returned results. Scores are + * normalized to the range of 0.0 to 1.0 before filtering. + * + * @return The minimum score threshold, used to filter returned results. + */ + public Float getScoreThreshold() { + return scoreThreshold; + } + + /** + * Sets the minimum score threshold, used to filter returned results. Scores are + * normalized to the range of 0.0 to 1.0 before filtering. + * + * @param withScoreThreshold The minimum score threshold, used to filter + * returned results. + */ + public void setScoreThreshold(Float withScoreThreshold) { + this.scoreThreshold = withScoreThreshold; + } + + /** + * Gets the time in milliseconds to wait before the request times out. + * + * @return The time in milliseconds to wait before the request times out. + * Default is 100000 milliseconds. This property allows users to set + * Timeout without having to pass in a custom HttpClient to QnAMaker + * class constructor. If using custom HttpClient, then set Timeout value + * in HttpClient instead of QnAMakerOptions.Timeout. + */ + public Double getTimeout() { + return timeout; + } + + /** + * Sets the time in milliseconds to wait before the request times out. + * + * @param withTimeout The time in milliseconds to wait before the request times + * out. Default is 100000 milliseconds. This property allows + * users to set Timeout without having to pass in a custom + * HttpClient to QnAMaker class constructor. If using custom + * HttpClient, then set Timeout value in HttpClient instead + * of QnAMakerOptions.Timeout. + */ + public void setTimeout(Double withTimeout) { + this.timeout = withTimeout; + } + + /** + * Gets the number of ranked results you want in the output. + * + * @return The number of ranked results you want in the output. + */ + public Integer getTop() { + return top; + } + + /** + * Sets the number of ranked results you want in the output. + * + * @param withTop The number of ranked results you want in the output. + */ + public void setTop(Integer withTop) { + this.top = withTop; + } + + /** + * Gets context of the previous turn. + * + * @return The context of previous turn. + */ + public QnARequestContext getContext() { + return context; + } + + /** + * Sets context of the previous turn. + * + * @param withContext The context of previous turn. + */ + public void setContext(QnARequestContext withContext) { + this.context = withContext; + } + + /** + * Gets QnA Id of the current question asked (if availble). + * + * @return Id of the current question asked. + */ + public Integer getQnAId() { + return qnAId; + } + + /** + * Sets QnA Id of the current question asked (if availble). + * + * @param withQnAId Id of the current question asked. + */ + public void setQnAId(Integer withQnAId) { + this.qnAId = withQnAId; + } + + /** + * Gets the {@link Metadata} collection to be sent when calling QnA Maker to + * filter results. + * + * @return An array of {@link Metadata} + */ + public Metadata[] getStrictFilters() { + return strictFilters; + } + + /** + * Sets the {@link Metadata} collection to be sent when calling QnA Maker to + * filter results. + * + * @param withStrictFilters An array of {@link Metadata} + */ + public void setStrictFilters(Metadata[] withStrictFilters) { + this.strictFilters = withStrictFilters; + } + + /** + * Gets a value indicating whether to call test or prod environment of knowledge + * base to be called. + * + * @return A value indicating whether to call test or prod environment of + * knowledge base. + */ + public Boolean getIsTest() { + return isTest; + } + + /** + * Sets a value indicating whether to call test or prod environment of knowledge + * base to be called. + * + * @param withIsTest A value indicating whether to call test or prod environment + * of knowledge base. + */ + public void setIsTest(Boolean withIsTest) { + isTest = withIsTest; + } + + /** + * Gets the QnA Maker ranker type to use. + * + * @return The QnA Maker ranker type to use. + */ + public String getRankerType() { + return rankerType; + } + + /** + * Sets the QnA Maker ranker type to use. + * + * @param withRankerType The QnA Maker ranker type to use. + */ + public void setRankerType(String withRankerType) { + this.rankerType = withRankerType; + } + + /** + * Gets Strict Filters join operator. + * + * @return A value indicating choice for Strict Filters Join Operation. + */ + public JoinOperator getStrictFiltersJoinOperator() { + return strictFiltersJoinOperator; + } + + /** + * Sets Strict Filters join operator. + * + * @param withStrictFiltersJoinOperator A value indicating choice for Strict + * Filters Join Operation. + */ + public void setStrictFiltersJoinOperator(JoinOperator withStrictFiltersJoinOperator) { + this.strictFiltersJoinOperator = withStrictFiltersJoinOperator; + } + + /** + * Initializes a new instance of the {@link QnAMakerOptions} class. + */ + public QnAMakerOptions() { + this.scoreThreshold = SCORE_THRESHOLD; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerRecognizer.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerRecognizer.java new file mode 100644 index 000000000..b93e7f788 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/QnAMakerRecognizer.java @@ -0,0 +1,542 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.base.Strings; +import com.microsoft.bot.ai.qna.models.Metadata; +import com.microsoft.bot.ai.qna.models.QnARequestContext; +import com.microsoft.bot.ai.qna.models.QueryResult; +import com.microsoft.bot.ai.qna.models.RankerTypes; +import com.microsoft.bot.builder.IntentScore; +import com.microsoft.bot.builder.RecognizerResult; +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.dialogs.Recognizer; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.Serialization; + +/** + * IRecognizer implementation which uses QnAMaker KB to identify intents. + */ +public class QnAMakerRecognizer extends Recognizer { + + private static final Integer TOP_DEFAULT_VALUE = 3; + private static final Float THRESHOLD_DEFAULT_VALUE = 0.3f; + + private final String qnAMatchIntent = "QnAMatch"; + + private final String intentPrefix = "intent="; + + @JsonProperty("knowledgeBaseId") + private String knowledgeBaseId; + + @JsonProperty("hostname") + private String hostName; + + @JsonProperty("endpointKey") + private String endpointKey; + + @JsonProperty("top") + private Integer top = TOP_DEFAULT_VALUE; + + @JsonProperty("threshold") + private Float threshold = THRESHOLD_DEFAULT_VALUE; + + @JsonProperty("isTest") + private Boolean isTest; + + @JsonProperty("rankerType") + private String rankerType = RankerTypes.DEFAULT_RANKER_TYPE; + + @JsonProperty("strictFiltersJoinOperator") + private JoinOperator strictFiltersJoinOperator; + + @JsonProperty("includeDialogNameInMetadata") + private Boolean includeDialogNameInMetadata = true; + + @JsonProperty("metadata") + private Metadata[] metadata; + + @JsonProperty("context") + private QnARequestContext context; + + @JsonProperty("qnaId") + private Integer qnAId = 0; + + @JsonProperty("logPersonalInformation") + private Boolean logPersonalInformation = false; + + /** + * Gets key used when adding the intent to the {@link RecognizerResult} intents + * collection. + * + * @return Key used when adding the intent to the {@link RecognizerResult} + * intents collection. + */ + public String getQnAMatchIntent() { + return qnAMatchIntent; + } + + /** + * Gets the KnowledgeBase Id of your QnA Maker KnowledgeBase. + * + * @return The knowledgebase Id. + */ + public String getKnowledgeBaseId() { + return knowledgeBaseId; + } + + /** + * Sets the KnowledgeBase Id of your QnA Maker KnowledgeBase. + * + * @param withKnowledgeBaseId The knowledgebase Id. + */ + public void setKnowledgeBaseId(String withKnowledgeBaseId) { + this.knowledgeBaseId = withKnowledgeBaseId; + } + + /** + * Gets the Hostname for your QnA Maker service. + * + * @return The host name of the QnA Maker knowledgebase. + */ + public String getHostName() { + return hostName; + } + + /** + * Sets the Hostname for your QnA Maker service. + * + * @param withHostName The host name of the QnA Maker knowledgebase. + */ + public void setHostName(String withHostName) { + this.hostName = withHostName; + } + + /** + * Gets the Endpoint key for the QnA Maker KB. + * + * @return The endpoint key for the QnA service. + */ + public String getEndpointKey() { + return endpointKey; + } + + /** + * Sets the Endpoint key for the QnA Maker KB. + * + * @param withEndpointKey The endpoint key for the QnA service. + */ + public void setEndpointKey(String withEndpointKey) { + this.endpointKey = withEndpointKey; + } + + /** + * Gets the number of results you want. + * + * @return The number of results you want. + */ + public Integer getTop() { + return top; + } + + /** + * Sets the number of results you want. + * + * @param withTop The number of results you want. + */ + public void setTop(Integer withTop) { + this.top = withTop; + } + + /** + * Gets the threshold score to filter results. + * + * @return The threshold for the results. + */ + public Float getThreshold() { + return threshold; + } + + /** + * Sets the threshold score to filter results. + * + * @param withThreshold The threshold for the results. + */ + public void setThreshold(Float withThreshold) { + this.threshold = withThreshold; + } + + /** + * Gets a value indicating whether gets or sets environment of knowledgebase to + * be called. + * + * @return A value indicating whether to call test or prod environment of + * knowledgebase. + */ + public Boolean getIsTest() { + return isTest; + } + + /** + * Sets a value indicating whether gets or sets environment of knowledgebase to + * be called. + * + * @param withIsTest A value indicating whether to call test or prod environment of + * knowledgebase. + */ + public void setIsTest(Boolean withIsTest) { + this.isTest = withIsTest; + } + + /** + * Gets ranker Type. + * + * @return The desired RankerType. + */ + public String getRankerType() { + return rankerType; + } + + /** + * Sets ranker Type. + * + * @param withRankerType The desired RankerType. + */ + public void setRankerType(String withRankerType) { + this.rankerType = withRankerType; + } + + /** + * Gets {@link Metadata} join operator. + * + * @return A value used for Join operation of Metadata . + */ + public JoinOperator getStrictFiltersJoinOperator() { + return strictFiltersJoinOperator; + } + + /** + * Sets {@link Metadata} join operator. + * + * @param withStrictFiltersJoinOperator A value used for Join operation of Metadata + * {@link Metadata}. + */ + public void setStrictFiltersJoinOperator(JoinOperator withStrictFiltersJoinOperator) { + this.strictFiltersJoinOperator = withStrictFiltersJoinOperator; + } + + /** + * Gets the whether to include the dialog name metadata for QnA context. + * + * @return A bool or boolean expression. + */ + public Boolean getIncludeDialogNameInMetadata() { + return includeDialogNameInMetadata; + } + + /** + * Sets the whether to include the dialog name metadata for QnA context. + * + * @param withIncludeDialogNameInMetadata A bool or boolean expression. + */ + public void setIncludeDialogNameInMetadata(Boolean withIncludeDialogNameInMetadata) { + this.includeDialogNameInMetadata = withIncludeDialogNameInMetadata; + } + + /** + * Gets an expression to evaluate to set additional metadata name value pairs. + * + * @return An expression to evaluate for pairs of metadata. + */ + public Metadata[] getMetadata() { + return metadata; + } + + /** + * Sets an expression to evaluate to set additional metadata name value pairs. + * + * @param withMetadata An expression to evaluate for pairs of metadata. + */ + public void setMetadata(Metadata[] withMetadata) { + this.metadata = withMetadata; + } + + /** + * Gets an expression to evaluate to set the context. + * + * @return An expression to evaluate to QnARequestContext to pass as context. + */ + public QnARequestContext getContext() { + return context; + } + + /** + * Sets an expression to evaluate to set the context. + * + * @param withContext An expression to evaluate to QnARequestContext to pass as + * context. + */ + public void setContext(QnARequestContext withContext) { + this.context = withContext; + } + + /** + * Gets an expression or numberto use for the QnAId paratemer. + * + * @return The expression or number. + */ + public Integer getQnAId() { + return qnAId; + } + + /** + * Sets an expression or numberto use for the QnAId paratemer. + * + * @param withQnAId The expression or number. + */ + public void setQnAId(Integer withQnAId) { + this.qnAId = withQnAId; + } + + /** + * Gets the flag to determine if personal information should be logged in + * telemetry. + * + * @return The flag to indicate in personal information should be logged in + * telemetry. + */ + public Boolean getLogPersonalInformation() { + return logPersonalInformation; + } + + /** + * Sets the flag to determine if personal information should be logged in + * telemetry. + * + * @param withLogPersonalInformation The flag to indicate in personal information + * should be logged in telemetry. + */ + public void setLogPersonalInformation(Boolean withLogPersonalInformation) { + this.logPersonalInformation = withLogPersonalInformation; + } + + /** + * Return results of the call to QnA Maker. + * + * @param dialogContext Context object containing information for a single + * turn of conversation with a user. + * @param activity The incoming activity received from the user. The + * Text property value is used as the query text for + * QnA Maker. + * @param telemetryProperties Additional properties to be logged to telemetry + * with the LuisResult event. + * @param telemetryMetrics Additional metrics to be logged to telemetry with + * the LuisResult event. + * @return A {@link RecognizerResult} containing the QnA Maker result. + */ + @Override + public CompletableFuture recognize(DialogContext dialogContext, Activity activity, + Map telemetryProperties, Map telemetryMetrics) { + // Identify matched intents + RecognizerResult recognizerResult = new RecognizerResult(); + recognizerResult.setText(activity.getText()); + recognizerResult.setIntents(new HashMap()); + if (Strings.isNullOrEmpty(activity.getText())) { + recognizerResult.getIntents().put("None", new IntentScore()); + return CompletableFuture.completedFuture(recognizerResult); + } + + List filters = new ArrayList(); + // TODO this should be uncommented as soon as Expression is added in Java + /* if (this.includeDialogNameInMetadata.getValue(dialogContext.getState())) { + filters.add(new Metadata() { + { + setName("dialogName"); + setValue(dialogContext.getActiveDialog().getId()); + } + }); + } */ + + // if there is $qna.metadata set add to filters + Metadata[] externalMetadata = this.metadata; + if (externalMetadata != null) { + filters.addAll(Arrays.asList(externalMetadata)); + } + + QnAMakerOptions options = new QnAMakerOptions() { + { + setContext(context); + setThreshold(threshold); + setStrictFilters(filters.toArray(new Metadata[filters.size()])); + setTop(top); + setQnAId(qnAId); + setIsTest(isTest); + setStrictFiltersJoinOperator(strictFiltersJoinOperator); + } + }; + // Calling QnAMaker to get response. + return this.getQnAMakerClient(dialogContext).thenCompose(qnaClient -> { + return qnaClient.getAnswers(dialogContext.getContext(), options, null, null).thenApply(answers -> { + if (answers.length > 0) { + QueryResult topAnswer = null; + for (QueryResult answer : answers) { + if (topAnswer == null || answer.getScore() > topAnswer.getScore()) { + topAnswer = answer; + } + } + Float internalTopAnswer = topAnswer.getScore(); + if (topAnswer.getAnswer().trim().toUpperCase().startsWith(intentPrefix.toUpperCase())) { + recognizerResult.getIntents().put( + topAnswer.getAnswer().trim().substring(intentPrefix.length()).trim(), + new IntentScore() { + { + setScore(internalTopAnswer); + } + }); + } else { + recognizerResult.getIntents().put(this.qnAMatchIntent, new IntentScore() { + { + setScore(internalTopAnswer); + } + }); + } + ObjectMapper mapper = new ObjectMapper(); + ObjectNode entitiesNode = mapper.createObjectNode(); + List answerArray = new ArrayList(); + answerArray.add(topAnswer.getAnswer()); + ArrayNode entitiesArrayNode = entitiesNode.putArray("answer"); + entitiesArrayNode.add(topAnswer.getAnswer()); + + ObjectNode instance = entitiesNode.putObject("$instance"); + ArrayNode instanceArrayNode = instance.putArray("answer"); + ObjectNode data = instanceArrayNode.addObject(); + data.setAll((ObjectNode) mapper.valueToTree(topAnswer)); + data.put("startIndex", 0); + data.put("endIndex", activity.getText().length()); + + recognizerResult.setEntities(entitiesNode); + recognizerResult.getProperties().put("answers", mapper.valueToTree(answers)); + } else { + recognizerResult.getIntents().put("None", new IntentScore() { + { + setScore(1.0f); + } + }); + } + + this.trackRecognizerResult(dialogContext, "QnAMakerRecognizerResult", this + .fillRecognizerResultTelemetryProperties(recognizerResult, telemetryProperties, dialogContext), + telemetryMetrics); + return recognizerResult; + }); + }); + } + + /** + * Gets an instance of {@link QnAMakerClient}. + * + * @param dc The {@link DialogContext} used to access state. + * @return An instance of {@link QnAMakerClient}. + */ + protected CompletableFuture getQnAMakerClient(DialogContext dc) { + QnAMakerClient qnaClient = dc.getContext().getTurnState().get(QnAMakerClient.class); + if (qnaClient != null) { + // return mock client + return CompletableFuture.completedFuture(qnaClient); + } + + String epKey = this.endpointKey; + String hn = this.hostName; + String kbId = this.knowledgeBaseId; + Boolean logPersonalInfo = this.logPersonalInformation; + QnAMakerEndpoint endpoint = new QnAMakerEndpoint() { + { + setEndpointKey(epKey); + setHost(hn); + setKnowledgeBaseId(kbId); + } + }; + + return CompletableFuture.completedFuture( + new QnAMaker(endpoint, new QnAMakerOptions(), this.getTelemetryClient(), logPersonalInfo)); + } + + /** + * Uses the RecognizerResult to create a list of properties to be included when + * tracking the result in telemetry. + * + * @param recognizerResult Recognizer Result. + * @param telemetryProperties A list of properties to append or override the + * properties created using the RecognizerResult. + * @param dialogContext Dialog Context. + * @return A dictionary that can be included when calling the TrackEvent method + * on the TelemetryClient. + */ + @Override + protected Map fillRecognizerResultTelemetryProperties(RecognizerResult recognizerResult, + Map telemetryProperties, @Nullable DialogContext dialogContext) { + if (dialogContext == null) { + throw new IllegalArgumentException( + "dialogContext: DialogContext needed for state in " + + "AdaptiveRecognizer.FillRecognizerResultTelemetryProperties method."); + } + + Map properties = new HashMap() { + { + put("TopIntent", + !recognizerResult.getIntents().isEmpty() + ? (String) recognizerResult.getIntents().keySet().toArray()[0] + : null); + put("TopIntentScore", + !recognizerResult.getIntents().isEmpty() + ? Double.toString( + ((IntentScore) recognizerResult.getIntents().values().toArray()[0]).getScore()) + : null); + put("Intents", + !recognizerResult.getIntents().isEmpty() + ? Serialization.toStringSilent(recognizerResult.getIntents()) + : null); + put("Entities", + recognizerResult.getEntities() != null + ? Serialization.toStringSilent(recognizerResult.getEntities()) + : null); + put("AdditionalProperties", + !recognizerResult.getProperties().isEmpty() + ? Serialization.toStringSilent(recognizerResult.getProperties()) + : null); + } + }; + + if (logPersonalInformation && !Strings.isNullOrEmpty(recognizerResult.getText())) { + properties.put("Text", recognizerResult.getText()); + properties.put("AlteredText", recognizerResult.getAlteredText()); + } + + // Additional Properties can override "stock properties". + if (telemetryProperties != null) { + telemetryProperties.putAll(properties); + Map> telemetryPropertiesMap = telemetryProperties.entrySet().stream().collect( + Collectors.groupingBy(Entry::getKey, Collectors.mapping(Entry::getValue, Collectors.toList()))); + return telemetryPropertiesMap.entrySet().stream() + .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().get(0))); + } + + return properties; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/TelemetryQnAMaker.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/TelemetryQnAMaker.java new file mode 100644 index 000000000..84152e768 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/TelemetryQnAMaker.java @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import com.microsoft.bot.ai.qna.models.QueryResult; +import com.microsoft.bot.builder.BotTelemetryClient; +import com.microsoft.bot.builder.TurnContext; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import javax.annotation.Nullable; + +/** + * Interface for adding telemetry logging capabilities to {@link QnAMaker}/>. + */ +public interface TelemetryQnAMaker { + + /** + * Gets a value indicating whether determines whether to log personal + * information that came from the user. + * + * @return If true, will log personal information into the + * IBotTelemetryClient.TrackEvent method; otherwise the properties will + * be filtered. + */ + Boolean getLogPersonalInformation(); + + /** + * Gets the currently configured {@link BotTelemetryClient} that logs the + * QnaMessage event. + * + * @return The {@link BotTelemetryClient} being used to log events. + */ + BotTelemetryClient getTelemetryClient(); + + /** + * Generates an answer from the knowledge base. + * + * @param turnContext The Turn Context that contains the user question + * to be queried against your knowledge base. + * @param options The options for the QnA Maker knowledge base. If + * null, constructor option is used for this + * instance. + * @param telemetryProperties Additional properties to be logged to telemetry + * with the QnaMessage event. + * @param telemetryMetrics Additional metrics to be logged to telemetry with + * the QnaMessage event. + * @return A list of answers for the user query, sorted in decreasing order of + * ranking score. + */ + CompletableFuture getAnswers(TurnContext turnContext, QnAMakerOptions options, + Map telemetryProperties, @Nullable Map telemetryMetrics); +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/QnAMakerDialog.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/QnAMakerDialog.java new file mode 100644 index 000000000..3a41b72cd --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/QnAMakerDialog.java @@ -0,0 +1,972 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.dialogs; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import javax.annotation.Nullable; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.microsoft.bot.ai.qna.QnADialogResponseOptions; +import com.microsoft.bot.ai.qna.QnAMaker; +import com.microsoft.bot.ai.qna.QnAMakerClient; +import com.microsoft.bot.ai.qna.QnAMakerEndpoint; +import com.microsoft.bot.ai.qna.QnAMakerOptions; +import com.microsoft.bot.ai.qna.models.FeedbackRecord; +import com.microsoft.bot.ai.qna.models.FeedbackRecords; +import com.microsoft.bot.ai.qna.models.Metadata; +import com.microsoft.bot.ai.qna.models.QnARequestContext; +import com.microsoft.bot.ai.qna.models.QueryResult; +import com.microsoft.bot.ai.qna.models.QueryResults; +import com.microsoft.bot.ai.qna.models.RankerTypes; +import com.microsoft.bot.ai.qna.utils.ActiveLearningUtils; +import com.microsoft.bot.ai.qna.utils.BindToActivity; +import com.microsoft.bot.ai.qna.utils.QnACardBuilder; +import com.microsoft.bot.builder.MessageFactory; +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.dialogs.DialogEvent; +import com.microsoft.bot.dialogs.DialogReason; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.dialogs.DialogTurnStatus; +import com.microsoft.bot.dialogs.ObjectPath; +import com.microsoft.bot.dialogs.TurnPath; +import com.microsoft.bot.dialogs.WaterfallDialog; +import com.microsoft.bot.dialogs.WaterfallStepContext; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; + +import okhttp3.OkHttpClient; +import org.slf4j.LoggerFactory; + +/** + * A dialog that supports multi-step and adaptive-learning QnA Maker services. + * An instance of this class targets a specific QnA Maker knowledge base. It + * supports knowledge bases that include follow-up prompt and active learning + * features. + */ +public class QnAMakerDialog extends WaterfallDialog { + + @JsonIgnore + private OkHttpClient httpClient; + + @JsonProperty("knowledgeBaseId") + private String knowledgeBaseId; + + @JsonProperty("hostName") + private String hostName; + + @JsonProperty("endpointKey") + private String endpointKey; + + @JsonProperty("threshold") + private Float threshold = DEFAULT_THRESHOLD; + + @JsonProperty("top") + private Integer top = DEFAULT_TOP_N; + + @JsonProperty("noAnswer") + private BindToActivity noAnswer = new BindToActivity(MessageFactory.text(DEFAULT_NO_ANSWER)); + + @JsonProperty("activeLearningCardTitle") + private String activeLearningCardTitle; + + @JsonProperty("cardNoMatchText") + private String cardNoMatchText; + + @JsonProperty("cardNoMatchResponse") + private BindToActivity cardNoMatchResponse = new BindToActivity( + MessageFactory.text(DEFAULT_CARD_NO_MATCH_RESPONSE)); + + @JsonProperty("strictFilters") + private Metadata[] strictFilters; + + @JsonProperty("logPersonalInformation") + private Boolean logPersonalInformation = false; + + @JsonProperty("isTest") + private Boolean isTest; + + @JsonProperty("rankerType") + private String rankerType = RankerTypes.DEFAULT_RANKER_TYPE; + + /** + * The path for storing and retrieving QnA Maker context data. This represents + * context about the current or previous call to QnA Maker. It is stored within + * the current step's {@link WaterfallStepContext}. It supports QnA Maker's + * follow-up prompt and active learning features. + */ + protected static final String QNA_CONTEXT_DATA = "qnaContextData"; + + /** + * The path for storing and retrieving the previous question ID. This represents + * the QnA question ID from the previous turn. It is stored within the current + * step's {@link WaterfallStepContext}. It supports QnA Maker's follow-up prompt + * and active learning features. + */ + protected static final String PREVIOUS_QNA_ID = "prevQnAId"; + + /** + * The path for storing and retrieving the options for this instance of the + * dialog. This includes the options with which the dialog was started and + * options expected by the QnA Maker service. It is stored within the current + * step's {@link WaterfallStepContext}. It supports QnA Maker and the dialog + * system. + */ + protected static final String OPTIONS = "options"; + + // Dialog Options parameters + + /** + * The default threshold for answers returned, based on score. + */ + protected static final Float DEFAULT_THRESHOLD = 0.3F; + + /** + * The default maximum number of answers to be returned for the question. + */ + protected static final Integer DEFAULT_TOP_N = 3; + + private static final String DEFAULT_NO_ANSWER = "No QnAMaker answers found."; + + // Card parameters + private static final String DEFAULT_CARD_TITLE = "Did you mean:"; + private static final String DEFAULT_CARD_NO_MATCH_TEXT = "None of the above."; + private static final String DEFAULT_CARD_NO_MATCH_RESPONSE = "Thanks for the feedback."; + private static final Integer PERCENTAGE_DIVISOR = 100; + + /** + * Gets the OkHttpClient instance to use for requests to the QnA Maker service. + * + * @return The HTTP client. + */ + public OkHttpClient getHttpClient() { + return this.httpClient; + } + + /** + * Gets the OkHttpClient instance to use for requests to the QnA Maker service. + * + * @param withHttpClient The HTTP client. + */ + public void setHttpClient(OkHttpClient withHttpClient) { + this.httpClient = withHttpClient; + } + + /** + * Gets the QnA Maker knowledge base ID to query. + * + * @return The knowledge base ID or an expression which evaluates to the + * knowledge base ID. + */ + public String getKnowledgeBaseId() { + return this.knowledgeBaseId; + } + + /** + * Sets the QnA Maker knowledge base ID to query. + * + * @param withKnowledgeBaseId The knowledge base ID or an expression which + * evaluates to the knowledge base ID. + */ + public void setKnowledgeBaseId(String withKnowledgeBaseId) { + this.knowledgeBaseId = withKnowledgeBaseId; + } + + /** + * Gets the QnA Maker host URL for the knowledge base. + * + * @return The QnA Maker host URL or an expression which evaluates to the host + * URL. + */ + public String getHostName() { + return this.hostName; + } + + /** + * Sets the QnA Maker host URL for the knowledge base. + * + * @param withHostName The QnA Maker host URL or an expression which evaluates + * to the host URL. + */ + public void setHostName(String withHostName) { + this.hostName = withHostName; + } + + /** + * Gets the QnA Maker endpoint key to use to query the knowledge base. + * + * @return The QnA Maker endpoint key to use or an expression which evaluates to + * the endpoint key. + */ + public String getEndpointKey() { + return this.endpointKey; + } + + /** + * Sets the QnA Maker endpoint key to use to query the knowledge base. + * + * @param withEndpointKey The QnA Maker endpoint key to use or an expression + * which evaluates to the endpoint key. + */ + public void setEndpointKey(String withEndpointKey) { + this.endpointKey = withEndpointKey; + } + + /** + * Gets the threshold for answers returned, based on score. + * + * @return The threshold for answers returned or an expression which evaluates + * to the threshold. + */ + public Float getThreshold() { + return this.threshold; + } + + /** + * Sets the threshold for answers returned, based on score. + * + * @param withThreshold The threshold for answers returned or an expression + * which evaluates to the threshold. + */ + public void setThreshold(Float withThreshold) { + this.threshold = withThreshold; + } + + /** + * Gets the maximum number of answers to return from the knowledge base. + * + * @return The maximum number of answers to return from the knowledge base or an + * expression which evaluates to the maximum number to return. + */ + public Integer getTop() { + return this.top; + } + + /** + * Sets the maximum number of answers to return from the knowledge base. + * + * @param withTop The maximum number of answers to return from the knowledge + * base or an expression which evaluates to the maximum number to + * return. + */ + public void setTop(Integer withTop) { + this.top = withTop; + } + + /** + * Gets the template to send the user when QnA Maker does not find an answer. + * + * @return The template to send the user when QnA Maker does not find an answer. + */ + public BindToActivity getNoAnswer() { + return this.noAnswer; + } + + /** + * Sets the template to send the user when QnA Maker does not find an answer. + * + * @param withNoAnswer The template to send the user when QnA Maker does not + * find an answer. + */ + public void setNoAnswer(BindToActivity withNoAnswer) { + this.noAnswer = withNoAnswer; + } + + /** + * Gets the card title to use when showing active learning options to the user, + * if active learning is enabled. + * + * @return The path card title to use when showing active learning options to + * the user or an expression which evaluates to the card title. + */ + public String getActiveLearningCardTitle() { + return this.activeLearningCardTitle; + } + + /** + * Sets the card title to use when showing active learning options to the user, + * if active learning is enabled. + * + * @param withActiveLearningCardTitle The path card title to use when showing + * active learning options to the user or an + * expression which evaluates to the card + * title. + */ + public void setActiveLearningCardTitle(String withActiveLearningCardTitle) { + this.activeLearningCardTitle = withActiveLearningCardTitle; + } + + /** + * Gets the button text to use with active learning options, allowing a user to + * indicate none of the options are applicable. + * + * @return The button text to use with active learning options or an expression + * which evaluates to the button text. + */ + public String getCardNoMatchText() { + return this.cardNoMatchText; + } + + /** + * Sets the button text to use with active learning options, allowing a user to + * indicate none of the options are applicable. + * + * @param withCardNoMatchText The button text to use with active learning + * options or an expression which evaluates to the + * button text. + */ + public void setCardNoMatchText(String withCardNoMatchText) { + this.cardNoMatchText = withCardNoMatchText; + } + + /** + * Gets the template to send the user if they select the no match option on an + * active learning card. + * + * @return The template to send the user if they select the no match option on + * an active learning card. + */ + public BindToActivity getCardNoMatchResponse() { + return this.cardNoMatchResponse; + } + + /** + * Sets the template to send the user if they select the no match option on an + * active learning card. + * + * @param withCardNoMatchResponse The template to send the user if they select + * the no match option on an active learning + * card. + */ + public void setCardNoMatchResponse(BindToActivity withCardNoMatchResponse) { + this.cardNoMatchResponse = withCardNoMatchResponse; + } + + /** + * Gets the QnA Maker metadata with which to filter or boost queries to the + * knowledge base; or null to apply none. + * + * @return The QnA Maker metadata with which to filter or boost queries to the + * knowledge base or an expression which evaluates to the QnA Maker + * metadata. + */ + public Metadata[] getStrictFilters() { + return this.strictFilters; + } + + /** + * Sets the QnA Maker metadata with which to filter or boost queries to the + * knowledge base; or null to apply none. + * + * @param withStrictFilters The QnA Maker metadata with which to filter or boost + * queries to the knowledge base or an expression which + * evaluates to the QnA Maker metadata. + */ + public void setStrictFilters(Metadata[] withStrictFilters) { + this.strictFilters = withStrictFilters; + } + + /** + * Gets the flag to determine if personal information should be logged in + * telemetry. + * + * @return The flag to indicate in personal information should be logged in + * telemetry. + */ + public Boolean getLogPersonalInformation() { + return this.logPersonalInformation; + } + + /** + * Sets the flag to determine if personal information should be logged in + * telemetry. + * + * @param withLogPersonalInformation The flag to indicate in personal + * information should be logged in telemetry. + */ + public void setLogPersonalInformation(Boolean withLogPersonalInformation) { + this.logPersonalInformation = withLogPersonalInformation; + } + + /** + * Gets a value indicating whether gets or sets environment of knowledgebase to + * be called. + * + * @return A value indicating whether to call test or prod environment of + * knowledge base. + */ + public Boolean getIsTest() { + return this.isTest; + } + + /** + * Sets a value indicating whether gets or sets environment of knowledgebase to + * be called. + * + * @param withIsTest A value indicating whether to call test or prod environment + * of knowledge base. + */ + public void setIsTest(Boolean withIsTest) { + this.isTest = withIsTest; + } + + /** + * Gets the QnA Maker ranker type to use. + * + * @return The QnA Maker ranker type to use or an expression which evaluates to + * the ranker type. + */ + public String getRankerType() { + return this.rankerType; + } + + /** + * Sets the QnA Maker ranker type to use. + * + * @param withRankerType The QnA Maker ranker type to use or an expression which + * evaluates to the ranker type. + */ + public void setRankerType(String withRankerType) { + this.rankerType = withRankerType; + } + + /** + * Initializes a new instance of the @{link QnAMakerDialog} class. + * + * @param dialogId The ID of the @{link Dialog}. + * @param withKnowledgeBaseId The ID of the QnA Maker knowledge base to + * query. + * @param withEndpointKey The QnA Maker endpoint key to use to query + * the knowledge base. + * @param withHostName The QnA Maker host URL for the knowledge + * base, starting with "https://" and ending + * with "/qnamaker". + * @param withNoAnswer The activity to send the user when QnA + * Maker does not find an answer. + * @param withThreshold The threshold for answers returned, based + * on score. + * @param withActiveLearningCardTitle The card title to use when showing active + * learning options to the user, if active + * learning is enabled. + * @param withCardNoMatchText The button text to use with active + * learning options, allowing a user to + * indicate none of the options are + * applicable. + * @param withTop The maximum number of answers to return + * from the knowledge base. + * @param withCardNoMatchResponse The activity to send the user if they + * select the no match option on an active + * learning card. + * @param withStrictFilters QnA Maker metadata with which to filter or + * boost queries to the knowledge base; or + * null to apply none. + * @param withHttpClient An HTTP client to use for requests to the + * QnA Maker Service; or `null` to use a + * default client. + */ + @SuppressWarnings("checkstyle:ParameterNumber") + public QnAMakerDialog(String dialogId, String withKnowledgeBaseId, String withEndpointKey, String withHostName, + @Nullable Activity withNoAnswer, Float withThreshold, String withActiveLearningCardTitle, + String withCardNoMatchText, Integer withTop, @Nullable Activity withCardNoMatchResponse, + @Nullable Metadata[] withStrictFilters, @Nullable OkHttpClient withHttpClient) { + super(dialogId, null); + if (knowledgeBaseId == null) { + throw new IllegalArgumentException("knowledgeBaseId"); + } + this.knowledgeBaseId = withKnowledgeBaseId; + if (hostName == null) { + throw new IllegalArgumentException("hostName"); + } + this.hostName = withHostName; + if (withEndpointKey == null) { + throw new IllegalArgumentException("endpointKey"); + } + this.endpointKey = withEndpointKey; + this.threshold = withThreshold != null ? withThreshold : DEFAULT_THRESHOLD; + this.top = withTop != null ? withTop : DEFAULT_TOP_N; + this.activeLearningCardTitle = withActiveLearningCardTitle != null ? withActiveLearningCardTitle + : DEFAULT_CARD_TITLE; + this.cardNoMatchText = withCardNoMatchText != null ? withCardNoMatchText : DEFAULT_CARD_NO_MATCH_TEXT; + this.strictFilters = withStrictFilters; + this.noAnswer = new BindToActivity( + withNoAnswer != null ? withNoAnswer : MessageFactory.text(DEFAULT_NO_ANSWER)); + this.cardNoMatchResponse = new BindToActivity(withCardNoMatchResponse != null ? withCardNoMatchResponse + : MessageFactory.text(DEFAULT_CARD_NO_MATCH_RESPONSE)); + this.httpClient = withHttpClient; + + // add waterfall steps + this.addStep(this::callGenerateAnswer); + this.addStep(this::callTrain); + this.addStep(this::checkForMultiTurnPrompt); + this.addStep(this::displayQnAResult); + } + + /** + * Initializes a new instance of the {@link QnAMakerDialog} class. + * + * @param withKnowledgeBaseId The ID of the QnA Maker knowledge base to + * query. + * @param withEndpointKey The QnA Maker endpoint key to use to query + * the knowledge base. + * @param withHostName The QnA Maker host URL for the knowledge + * base, starting with "https://" and ending + * with "/qnamaker". + * @param withNoAnswer The activity to send the user when QnA + * Maker does not find an answer. + * @param withThreshold The threshold for answers returned, based + * on score. + * @param withActiveLearningCardTitle The card title to use when showing active + * learning options to the user, if active + * learning is enabled. + * @param withCardNoMatchText The button text to use with active + * learning options, allowing a user to + * indicate none of the options are + * applicable. + * @param withTop The maximum number of answers to return + * from the knowledge base. + * @param withCardNoMatchResponse The activity to send the user if they + * select the no match option on an active + * learning card. + * @param withStrictFilters QnA Maker metadata with which to filter or + * boost queries to the knowledge base; or + * null to apply none. + * @param withHttpClient An HTTP client to use for requests to the + * QnA Maker Service; or `null` to use a + * default client. + */ + @SuppressWarnings("checkstyle:ParameterNumber") + public QnAMakerDialog(String withKnowledgeBaseId, String withEndpointKey, String withHostName, + @Nullable Activity withNoAnswer, Float withThreshold, String withActiveLearningCardTitle, + String withCardNoMatchText, Integer withTop, @Nullable Activity withCardNoMatchResponse, + @Nullable Metadata[] withStrictFilters, + @Nullable OkHttpClient withHttpClient) { + this(QnAMakerDialog.class.getName(), withKnowledgeBaseId, withEndpointKey, withHostName, withNoAnswer, + withThreshold, withActiveLearningCardTitle, withCardNoMatchText, withTop, withCardNoMatchResponse, + withStrictFilters, withHttpClient); + } + + /** + * Called when the dialog is started and pushed onto the dialog stack. + * + * @param dc The @{link DialogContext} for the current turn of + * conversation. + * @param options Optional, initial information to pass to the dialog. + * @return A Task representing the asynchronous operation. If the task is + * successful, the result indicates whether the dialog is still active + * after the turn has been processed by the dialog. + * + * You can use the @{link options} parameter to include the QnA Maker + * context data, which represents context from the previous query. To do + * so, the value should include a `context` property of type @{link + * QnaResponseContext}. + */ + @Override + public CompletableFuture beginDialog(DialogContext dc, @Nullable Object options) { + if (dc == null) { + throw new IllegalArgumentException("dc"); + } + + if (!dc.getContext().getActivity().isType(ActivityTypes.MESSAGE)) { + return CompletableFuture.completedFuture(END_OF_TURN); + } + + QnAMakerOptions qnAMakerOptions = this.getQnAMakerOptions(dc).join(); + QnADialogResponseOptions qnADialogResponseOptions = this.getQnAResponseOptions(dc).join(); + + QnAMakerDialogOptions dialogOptions = new QnAMakerDialogOptions() { + { + setQnAMakerOptions(qnAMakerOptions); + setResponseOptions(qnADialogResponseOptions); + } + }; + + if (options != null) { + dialogOptions = (QnAMakerDialogOptions) ObjectPath.assign(dialogOptions, options); + } + + ObjectPath.setPathValue(dc.getActiveDialog().getState(), OPTIONS, dialogOptions); + + return super.beginDialog(dc, dialogOptions); + } + + /** + * {@inheritDoc} + */ + @Override + public CompletableFuture continueDialog(DialogContext dc) { + Boolean interrupted = dc.getState().getValue(TurnPath.INTERRUPTED, false, Boolean.class); + if (interrupted) { + // if qnamaker was interrupted then end the qnamaker dialog + return dc.endDialog(); + } + + return super.continueDialog(dc); + } + + /** + * {@inheritDoc} + */ + @Override + protected CompletableFuture onPreBubbleEvent(DialogContext dc, DialogEvent e) { + if (dc.getContext().getActivity().isType(ActivityTypes.MESSAGE)) { + // decide whether we want to allow interruption or not. + // if we don't get a response from QnA which signifies we expected it, + // then we allow interruption. + + String reply = dc.getContext().getActivity().getText(); + QnAMakerDialogOptions dialogOptions = (QnAMakerDialogOptions) ObjectPath + .getPathValue(dc.getActiveDialog().getState(), OPTIONS, QnAMakerDialogOptions.class); + + if (reply.equalsIgnoreCase(dialogOptions.getResponseOptions().getCardNoMatchText())) { + // it matches nomatch text, we like that. + return CompletableFuture.completedFuture(true); + } + + List suggestedQuestions = (List) dc.getState().get("this.suggestedQuestions"); + if (suggestedQuestions != null && suggestedQuestions.stream() + .anyMatch(question -> question.compareToIgnoreCase(reply.trim()) == 0)) { + // it matches one of the suggested actions, we like that. + return CompletableFuture.completedFuture(true); + } + + // Calling QnAMaker to get response. + return this.getQnAMakerClient(dc).thenCompose(qnaClient -> { + QnAMakerDialog.resetOptions(dc, dialogOptions); + + return qnaClient.getAnswersRaw(dc.getContext(), dialogOptions.getQnAMakerOptions(), + null, null) + .thenApply(response -> { + // cache result so step doesn't have to do it again, this is a turn cache and we + // use hashcode so we don't conflict with any other qnamakerdialogs out there. + dc.getState().setValue(String.format("turn.qnaresult%s", this.hashCode()), response); + + // disable interruption if we have answers. + return !(response.getAnswers().length == 0); + }); + }); + } + // call base for default behavior. + return this.onPostBubbleEvent(dc, e); + } + + /** + * Gets an {@link QnAMakerClient} to use to access the QnA Maker knowledge + * base. + * + * @param dc The {@link DialogContext} for the current turn of conversation. + * @return A Task representing the asynchronous operation. If the task is + * successful, the result contains the QnA Maker client to use. + */ + protected CompletableFuture getQnAMakerClient(DialogContext dc) { + QnAMakerClient qnaClient = (QnAMakerClient) dc.getContext().getTurnState(); + if (qnaClient != null) { + // return mock client + return CompletableFuture.completedFuture(qnaClient); + } + + QnAMakerEndpoint endpoint = new QnAMakerEndpoint() { + { + setEndpointKey(endpointKey); + setHost(hostName); + setKnowledgeBaseId(knowledgeBaseId); + } + }; + + return this.getQnAMakerOptions(dc).thenApply(options -> new QnAMaker(endpoint, options, + this.getTelemetryClient(), this.logPersonalInformation)); + } + + /** + * Gets the options for the QnA Maker client that the dialog will use to query + * the knowledge base. + * + * @param dc The for the current turn of + * conversation. + * @return A representing the asynchronous operation. If the + * task is successful, the result contains the QnA Maker options to use. + */ + protected CompletableFuture getQnAMakerOptions(DialogContext dc) { + return CompletableFuture.completedFuture(new QnAMakerOptions() { + { + setScoreThreshold(threshold); + setStrictFilters(strictFilters); + setTop(top); + setContext(new QnARequestContext()); + setQnAId(0); + setRankerType(rankerType); + setIsTest(isTest); + } + }); + } + + /** + * Gets the options the dialog will use to display query results to the user. + * + * @param dc The {@link DialogContext} for the current turn of conversation. + * @return A Task representing the asynchronous operation. If the task is + * successful, the result contains the response options to use. + */ + protected CompletableFuture getQnAResponseOptions(DialogContext dc) { + return CompletableFuture.completedFuture(new QnADialogResponseOptions() { + { + setNoAnswer(noAnswer.bind(dc, dc.getState()).join()); + setActiveLearningCardTitle(activeLearningCardTitle != null + ? activeLearningCardTitle + : DEFAULT_CARD_TITLE); + setCardNoMatchText( + cardNoMatchText != null ? cardNoMatchText + : DEFAULT_CARD_NO_MATCH_TEXT); + setCardNoMatchResponse(cardNoMatchResponse.bind(dc, null).join()); + } + }); + } + + /** + * Displays QnA Result from stepContext through Activity - with first answer + * from QnA Maker response. + * + * @param stepContext stepContext. + * @return An object of Task of type {@link DialogTurnResult}. + */ + protected CompletableFuture displayQnAResult(WaterfallStepContext stepContext) { + QnAMakerDialogOptions dialogOptions = ObjectPath.getPathValue(stepContext.getActiveDialog().getState(), + OPTIONS, QnAMakerDialogOptions.class); + String reply = stepContext.getContext().getActivity().getText(); + if (reply.compareToIgnoreCase(dialogOptions.getResponseOptions().getCardNoMatchText()) == 0) { + Activity activity = dialogOptions.getResponseOptions().getCardNoMatchResponse(); + if (activity == null) { + stepContext.getContext().sendActivity(DEFAULT_CARD_NO_MATCH_RESPONSE).join(); + } else { + stepContext.getContext().sendActivity(activity).join(); + } + + return stepContext.endDialog(); + } + + // If previous QnAId is present, replace the dialog + Integer previousQnAId = ObjectPath.getPathValue(stepContext.getActiveDialog().getState(), PREVIOUS_QNA_ID, + Integer.class, 0); + if (previousQnAId > 0) { + // restart the waterfall to step 0 + return this.runStep(stepContext, 0, DialogReason.BEGIN_CALLED, null); + } + + // If response is present then show that response, else default answer. + List response = (List) stepContext.getResult(); + if (response != null && response.size() > 0) { + stepContext.getContext().sendActivity(response.get(0).getAnswer()).join(); + } else { + Activity activity = dialogOptions.getResponseOptions().getNoAnswer(); + if (activity == null) { + stepContext.getContext().sendActivity(DEFAULT_NO_ANSWER).join(); + } else { + stepContext.getContext().sendActivity(activity).join(); + } + } + + return stepContext.endDialog(); + } + + private static void resetOptions(DialogContext dc, QnAMakerDialogOptions dialogOptions) { + // Resetting context and QnAId + dialogOptions.getQnAMakerOptions().setQnAId(0); + dialogOptions.getQnAMakerOptions().setContext(new QnARequestContext()); + + // -Check if previous context is present, if yes then put it with the query + // -Check for id if query is present in reverse index. + Map previousContextData = ObjectPath.getPathValue(dc.getActiveDialog().getState(), + QNA_CONTEXT_DATA, Map.class); + Integer previousQnAId = ObjectPath.getPathValue(dc.getActiveDialog().getState(), PREVIOUS_QNA_ID, + Integer.class, 0); + + if (previousQnAId > 0) { + dialogOptions.getQnAMakerOptions().setContext(new QnARequestContext() { + { + setPreviousQnAId(previousQnAId); + } + }); + + Integer currentQnAId = previousContextData.get(dc.getContext().getActivity().getText()); + if (currentQnAId != null) { + dialogOptions.getQnAMakerOptions().setQnAId(currentQnAId); + } + } + } + + private CompletableFuture callGenerateAnswer(WaterfallStepContext stepContext) { + // clear suggestedQuestions between turns. + stepContext.getState().removeValue("this.suggestedQuestions"); + + QnAMakerDialogOptions dialogOptions = ObjectPath.getPathValue(stepContext.getActiveDialog().getState(), + OPTIONS, QnAMakerDialogOptions.class); + QnAMakerDialog.resetOptions(stepContext, dialogOptions); + + // Storing the context info + stepContext.getValues().put(ValueProperty.CURRENT_QUERY, stepContext.getContext().getActivity().getText()); + + // Calling QnAMaker to get response. + return this.getQnAMakerClient(stepContext).thenApply(qnaMakerClient -> { + QueryResults response = (QueryResults) stepContext.getState() + .get(String.format("turn.qnaresult%s", this.hashCode())); + if (response == null) { + response = qnaMakerClient.getAnswersRaw(stepContext.getContext(), dialogOptions.getQnAMakerOptions(), + null, null) + .join(); + } + + // Resetting previous query. + Integer previousQnAId = -1; + ObjectPath.setPathValue(stepContext.getActiveDialog().getState(), PREVIOUS_QNA_ID, previousQnAId); + + // Take this value from GetAnswerResponse + Boolean isActiveLearningEnabled = response.getActiveLearningEnabled(); + + stepContext.getValues().put(ValueProperty.QNA_DATA, Arrays.asList(response.getAnswers())); + + // Check if active learning is enabled. + // MaximumScoreForLowScoreVariation is the score above which no need to check + // for feedback. + if (response.getAnswers().length > 0 && response.getAnswers()[0] + .getScore() <= (ActiveLearningUtils.getMaximumScoreForLowScoreVariation() / PERCENTAGE_DIVISOR)) { + // Get filtered list of the response that support low score variation criteria. + response.setAnswers(qnaMakerClient.getLowScoreVariation(response.getAnswers())); + + if (response.getAnswers().length > 1 && isActiveLearningEnabled) { + List suggestedQuestions = new ArrayList(); + for (QueryResult qna : response.getAnswers()) { + suggestedQuestions.add(qna.getQuestions()[0]); + } + + // Get active learning suggestion card activity. + Activity message = QnACardBuilder.getSuggestionsCard(suggestedQuestions, + dialogOptions.getResponseOptions().getActiveLearningCardTitle(), + dialogOptions.getResponseOptions().getCardNoMatchText()); + stepContext.getContext().sendActivity(message).join(); + + ObjectPath.setPathValue(stepContext.getActiveDialog().getState(), OPTIONS, dialogOptions); + stepContext.getState().setValue("this.suggestedQuestions", suggestedQuestions); + return new DialogTurnResult(DialogTurnStatus.WAITING); + } + } + + List result = new ArrayList(); + if (!(response.getAnswers().length == 0)) { + result.add(response.getAnswers()[0]); + } + + stepContext.getValues().put(ValueProperty.QNA_DATA, result); + ObjectPath.setPathValue(stepContext.getActiveDialog().getState(), OPTIONS, dialogOptions); + + // If card is not shown, move to next step with top QnA response. + return stepContext.next(result).join(); + }); + } + + private CompletableFuture callTrain(WaterfallStepContext stepContext) { + QnAMakerDialogOptions dialogOptions = ObjectPath.getPathValue(stepContext.getActiveDialog().getState(), OPTIONS, + QnAMakerDialogOptions.class); + List trainResponses = (List) stepContext.getValues().get(ValueProperty.QNA_DATA); + String currentQuery = (String) stepContext.getValues().get(ValueProperty.CURRENT_QUERY); + + String reply = stepContext.getContext().getActivity().getText(); + + if (trainResponses.size() > 1) { + QueryResult qnaResult = trainResponses + .stream().filter(kvp -> kvp.getQuestions()[0].equals(reply)).findFirst().orElse(null); + if (qnaResult != null) { + List queryResultArr = new ArrayList(); + stepContext.getValues().put(ValueProperty.QNA_DATA, queryResultArr.add(qnaResult)); + FeedbackRecord record = new FeedbackRecord() {{ + setUserId(stepContext.getContext().getActivity().getId()); + setUserQuestion(currentQuery); + setQnaId(qnaResult.getId()); + }}; + FeedbackRecord[] records = {record}; + FeedbackRecords feedbackRecords = new FeedbackRecords() {{ + setRecords(records); + }}; + // Call Active Learning Train API + return this.getQnAMakerClient(stepContext).thenCompose(qnaClient -> { + try { + return qnaClient.callTrain(feedbackRecords).thenCompose(task -> + stepContext.next(new ArrayList().add(qnaResult))); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerDialog.class).error("callTrain"); + } + return CompletableFuture.completedFuture(null); + }); + } else if (reply.compareToIgnoreCase(dialogOptions.getResponseOptions().getCardNoMatchText()) == 0) { + Activity activity = dialogOptions.getResponseOptions().getCardNoMatchResponse(); + if (activity == null) { + stepContext.getContext().sendActivity(DEFAULT_CARD_NO_MATCH_RESPONSE).join(); + } else { + stepContext.getContext().sendActivity(activity).join(); + } + + return stepContext.endDialog(); + } else { + // restart the waterfall to step 0 + return runStep(stepContext, 0, DialogReason.BEGIN_CALLED, null); + } + } + + return stepContext.next(stepContext.getResult()); + } + + private CompletableFuture checkForMultiTurnPrompt(WaterfallStepContext stepContext) { + QnAMakerDialogOptions dialogOptions = ObjectPath.getPathValue(stepContext.getActiveDialog().getState(), + OPTIONS, QnAMakerDialogOptions.class); + List response = (List) stepContext.getResult(); + if (response != null && response.size() > 0) { + // -Check if context is present and prompt exists + // -If yes: Add reverse index of prompt display name and its corresponding QnA + // ID + // -Set PreviousQnAId as answer.Id + // -Display card for the prompt + // -Wait for the reply + // -If no: Skip to next step + + QueryResult answer = response.get(0); + + if (answer.getContext() != null && answer.getContext().getPrompts().length > 0) { + Map previousContextData = ObjectPath.getPathValue( + stepContext.getActiveDialog().getState(), QNA_CONTEXT_DATA, Map.class); + + for (QnAMakerPrompt prompt : answer.getContext().getPrompts()) { + previousContextData.put(prompt.getDisplayText(), prompt.getQnaId()); + } + + ObjectPath.setPathValue(stepContext.getActiveDialog().getState(), + QNA_CONTEXT_DATA, previousContextData); + ObjectPath.setPathValue(stepContext.getActiveDialog().getState(), PREVIOUS_QNA_ID, answer.getId()); + ObjectPath.setPathValue(stepContext.getActiveDialog().getState(), OPTIONS, dialogOptions); + + // Get multi-turn prompts card activity. + Activity message = QnACardBuilder.getQnAPromptsCard(answer, + dialogOptions.getResponseOptions().getCardNoMatchText()); + stepContext.getContext().sendActivity(message).join(); + + return CompletableFuture.completedFuture(new DialogTurnResult(DialogTurnStatus.WAITING)); + } + } + + return stepContext.next(stepContext.getResult()); + } + + /** + * Helper class. + */ + final class ValueProperty { + public static final String CURRENT_QUERY = "currentQuery"; + public static final String QNA_DATA = "qnaData"; + + private ValueProperty() { } + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/QnAMakerDialogOptions.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/QnAMakerDialogOptions.java new file mode 100644 index 000000000..75122e83d --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/QnAMakerDialogOptions.java @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.dialogs; + +import com.microsoft.bot.ai.qna.QnADialogResponseOptions; +import com.microsoft.bot.ai.qna.QnAMakerOptions; + +/** + * Defines Dialog Options for QnAMakerDialog. + */ +public class QnAMakerDialogOptions { + private QnAMakerOptions qnaMakerOptions; + + private QnADialogResponseOptions responseOptions; + + /** + * Gets the options for the QnAMaker service. + * + * @return The options for the QnAMaker service. + */ + public QnAMakerOptions getQnAMakerOptions() { + return this.qnaMakerOptions; + } + + /** + * Sets the options for the QnAMaker service. + * + * @param withQnAMakerOptions The options for the QnAMaker service. + */ + public void setQnAMakerOptions(QnAMakerOptions withQnAMakerOptions) { + this.qnaMakerOptions = withQnAMakerOptions; + } + + /** + * Gets the response options for the QnAMakerDialog. + * + * @return The response options for the QnAMakerDialog. + */ + public QnADialogResponseOptions getResponseOptions() { + return this.responseOptions; + } + + /** + * Sets the response options for the QnAMakerDialog. + * + * @param withResponseOptions The response options for the QnAMakerDialog. + */ + public void setResponseOptions(QnADialogResponseOptions withResponseOptions) { + this.responseOptions = withResponseOptions; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/QnAMakerPrompt.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/QnAMakerPrompt.java new file mode 100644 index 000000000..eeb18295b --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/QnAMakerPrompt.java @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.dialogs; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Prompt Object. + */ +public class QnAMakerPrompt { + private static final Integer DEFAULT_DISPLAY_ORDER = 0; + + @JsonProperty("displayOrder") + private Integer displayOrder = QnAMakerPrompt.DEFAULT_DISPLAY_ORDER; + + @JsonProperty("qnaId") + private Integer qnaId; + + @JsonProperty("displayText") + private String displayText = new String(); + + @JsonProperty("qna") + private Object qna; + + /** + * Gets displayOrder - index of the prompt - used in ordering of the prompts. + * + * @return Display order. + */ + public Integer getDisplayOrder() { + return this.displayOrder; + } + + /** + * Sets displayOrder - index of the prompt - used in ordering of the prompts. + * + * @param withDisplayOrder Display order. + */ + public void setDisplayOrder(Integer withDisplayOrder) { + this.displayOrder = withDisplayOrder; + } + + /** + * Gets qna id corresponding to the prompt - if QnaId is present, QnADTO object + * is ignored. + * + * @return QnA Id. + */ + public Integer getQnaId() { + return this.qnaId; + } + + /** + * Sets qna id corresponding to the prompt - if QnaId is present, QnADTO object + * is ignored. + * + * @param withQnaId QnA Id. + */ + public void setQnaId(Integer withQnaId) { + this.qnaId = withQnaId; + } + + /** + * Gets displayText - Text displayed to represent a follow up question prompt. + * + * @return Display test. + */ + public String getDisplayText() { + return this.displayText; + } + + /** + * Sets displayText - Text displayed to represent a follow up question prompt. + * + * @param withDisplayText Display test. + */ + public void setDisplayText(String withDisplayText) { + this.displayText = withDisplayText; + } + + /** + * Gets the QnADTO returned from the API. + * + * @return The QnA DTO. + */ + public Object getQna() { + return this.qna; + } + + /** + * Sets the QnADTO returned from the API. + * + * @param withQna The QnA DTO. + */ + public void setQna(Object withQna) { + this.qna = withQna; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/package-info.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/package-info.java new file mode 100644 index 000000000..73805a3b0 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/dialogs/package-info.java @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for +// license information. + +/** + * This package contains the classes for Bot-Builder. + */ +package com.microsoft.bot.ai.qna.dialogs; diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/FeedbackRecord.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/FeedbackRecord.java new file mode 100644 index 000000000..c58ef5f0e --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/FeedbackRecord.java @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.models; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Active learning feedback record. + */ +public class FeedbackRecord { + @JsonProperty("userId") + private String userId; + + @JsonProperty("userQuestion") + private String userQuestion; + + @JsonProperty("qnaId") + private Integer qnaId; + + /** + * Gets the feedback recod's user ID. + * + * @return The user ID. + */ + public String getUserId() { + return this.userId; + } + + /** + * Sets the feedback recod's user ID. + * + * @param withUserId The user ID. + */ + public void setUserId(String withUserId) { + this.userId = withUserId; + } + + /** + * Gets the question asked by the user. + * + * @return The user question. + */ + public String getUserQuestion() { + return this.userQuestion; + } + + /** + * Sets question asked by the user. + * + * @param withUserQuestion The user question. + */ + public void setUserQuestion(String withUserQuestion) { + this.userQuestion = withUserQuestion; + } + + /** + * Gets the QnA ID. + * + * @return The QnA ID. + */ + public Integer getQnaId() { + return this.qnaId; + } + + /** + * Sets the QnA ID. + * + * @param withQnaId The QnA ID. + */ + public void setQnaId(Integer withQnaId) { + this.qnaId = withQnaId; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/FeedbackRecords.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/FeedbackRecords.java new file mode 100644 index 000000000..94bc084d4 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/FeedbackRecords.java @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.models; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Active learning feedback records. + */ +public class FeedbackRecords { + @JsonProperty("feedbackRecords") + private FeedbackRecord[] records; + + /** + * Gets the list of feedback records. + * + * @return List of {@link FeedbackRecord}. + */ + public FeedbackRecord[] getRecords() { + return this.records; + } + + /** + * Sets the list of feedback records. + * + * @param withRecords List of {@link FeedbackRecord}. + */ + public void setRecords(FeedbackRecord[] withRecords) { + this.records = withRecords; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/Metadata.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/Metadata.java new file mode 100644 index 000000000..62f02b160 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/Metadata.java @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.models; + +import java.io.Serializable; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents the Metadata object sent as part of QnA Maker requests. + */ +public class Metadata implements Serializable { + @JsonProperty("name") + private String name; + + @JsonProperty("value") + private String value; + + /** + * Gets the name for the Metadata property. + * + * @return A string. + */ + public String getName() { + return this.name; + } + + /** + * Sets the name for the Metadata property. + * + * @param withName A string. + */ + public void setName(String withName) { + this.name = withName; + } + + /** + * Gets the value for the Metadata property. + * + * @return A string. + */ + public String getValue() { + return this.value; + } + + /** + * Sets the value for the Metadata property. + * + * @param withValue A string. + */ + public void setValue(String withValue) { + this.value = withValue; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QnAMakerTraceInfo.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QnAMakerTraceInfo.java new file mode 100644 index 000000000..712cbcd38 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QnAMakerTraceInfo.java @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.models; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.microsoft.bot.schema.Activity; + +/** + * This class represents all the trace info that we collect from the QnAMaker + * Middleware. + */ +public class QnAMakerTraceInfo { + @JsonProperty("message") + private Activity message; + + @JsonProperty("queryResults") + private QueryResult[] queryResults; + + @JsonProperty("knowledgeBaseId") + private String knowledgeBaseId; + + @JsonProperty("scoreThreshold") + private Float scoreThreshold; + + @JsonProperty("top") + private Integer top; + + @JsonProperty("strictFilters") + private Metadata[] strictFilters; + + @JsonProperty("context") + private QnARequestContext context; + + @JsonProperty("qnaId") + private Integer qnaId; + + @JsonProperty("isTest") + private Boolean isTest; + + @JsonProperty("rankerType") + private String rankerType; + + @Deprecated + @JsonIgnore + private Metadata[] metadataBoost; + + /** + * Gets message which instigated the query to QnAMaker. + * + * @return Message which instigated the query to QnAMaker. + */ + public Activity getMessage() { + return this.message; + } + + /** + * Sets message which instigated the query to QnAMaker. + * + * @param withMessage Message which instigated the query to QnAMaker. + */ + public void setMessage(Activity withMessage) { + this.message = withMessage; + } + + /** + * Gets results that QnAMaker returned. + * + * @return Results that QnAMaker returned. + */ + public QueryResult[] getQueryResults() { + return this.queryResults; + } + + /** + * Sets results that QnAMaker returned. + * + * @param withQueryResult Results that QnAMaker returned. + */ + public void setQueryResults(QueryResult[] withQueryResult) { + this.queryResults = withQueryResult; + } + + /** + * Gets iD of the Knowledgebase that is being used. + * + * @return ID of the Knowledgebase that is being used. + */ + public String getKnowledgeBaseId() { + return this.knowledgeBaseId; + } + + /** + * Sets iD of the Knowledgebase that is being used. + * + * @param withKnowledgeBaseId ID of the Knowledgebase that is being used. + */ + public void setKnowledgeBaseId(String withKnowledgeBaseId) { + this.knowledgeBaseId = withKnowledgeBaseId; + } + + /** + * Gets the minimum score threshold, used to filter returned results. Scores are + * normalized to the range of 0.0 to 1.0 before filtering. + * + * @return The minimum score threshold, used to filter returned results. + */ + public Float getScoreThreshold() { + return this.scoreThreshold; + } + + /** + * Sets the minimum score threshold, used to filter returned results. Scores are + * normalized to the range of 0.0 to 1.0 before filtering + * + * @param withScoreThreshold The minimum score threshold, used to filter + * returned results. + */ + public void setScoreThreshold(Float withScoreThreshold) { + this.scoreThreshold = withScoreThreshold; + } + + /** + * Gets number of ranked results that are asked to be returned. + * + * @return Number of ranked results that are asked to be returned. + */ + public Integer getTop() { + return this.top; + } + + /** + * Sets number of ranked results that are asked to be returned. + * + * @param withTop Number of ranked results that are asked to be returned. + */ + public void setTop(Integer withTop) { + this.top = withTop; + } + + /** + * Gets the filters used to return answers that have the specified metadata. + * + * @return The filters used to return answers that have the specified metadata. + */ + public Metadata[] getStrictFilters() { + return this.strictFilters; + } + + /** + * Sets the filters used to return answers that have the specified metadata. + * + * @param withStrictFilters The filters used to return answers that have the + * specified metadata. + */ + public void setStrictFilters(Metadata[] withStrictFilters) { + this.strictFilters = withStrictFilters; + } + + /** + * Gets context for multi-turn responses. + * + * @return The context from which the QnA was extracted. + */ + public QnARequestContext getContext() { + return this.context; + } + + /** + * Sets context for multi-turn responses. + * + * @param withContext The context from which the QnA was extracted. + */ + public void setContext(QnARequestContext withContext) { + this.context = withContext; + } + + /** + * Gets QnA Id of the current question asked. + * + * @return Id of the current question asked. + */ + public Integer getQnAId() { + return this.qnaId; + } + + /** + * Sets QnA Id of the current question asked. + * + * @param withQnAId Id of the current question asked. + */ + public void setQnAId(Integer withQnAId) { + this.qnaId = withQnAId; + } + + /** + * Gets a value indicating whether gets or sets environment of knowledgebase to + * be called. + * + * @return A value indicating whether to call test or prod environment of + * knowledgebase. + */ + public Boolean getIsTest() { + return this.isTest; + } + + /** + * Sets a value indicating whether gets or sets environment of knowledgebase to + * be called. + * + * @param withIsTest A value indicating whether to call test or prod environment + * of knowledgebase. + */ + public void setIsTest(Boolean withIsTest) { + this.isTest = withIsTest; + } + + /** + * Gets ranker Types. + * + * @return Ranker Types. + */ + public String getRankerType() { + return this.rankerType; + } + + /** + * Sets ranker Types. + * + * @param withRankerType Ranker Types. + */ + public void setRankerType(String withRankerType) { + this.rankerType = withRankerType; + } + + /** + * Gets the {@link Metadata} collection to be sent when calling QnA Maker to + * boost results. + * + * @return An array of {@link Metadata}. + */ + public Metadata[] getMetadataBoost() { + return this.metadataBoost; + } + + /** + * Sets the {@link Metadata} collection to be sent when calling QnA Maker to + * boost results. + * + * @param withMetadataBoost An array of {@link Metadata}. + */ + public void setMetadataBoost(Metadata[] withMetadataBoost) { + this.metadataBoost = withMetadataBoost; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QnARequestContext.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QnARequestContext.java new file mode 100644 index 000000000..f5134d6ca --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QnARequestContext.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.models; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The context associated with QnA. Used to mark if the current prompt is + * relevant with a previous question or not. + */ +public class QnARequestContext { + @JsonProperty("previousQnAId") + private Integer previousQnAId; + + @JsonProperty("previousUserQuery") + private String previousUserQuery = new String(); + + /** + * Gets the previous QnA Id that was returned. + * + * @return The previous QnA Id. + */ + public Integer getPreviousQnAId() { + return this.previousQnAId; + } + + /** + * Sets the previous QnA Id that was returned. + * + * @param withPreviousQnAId The previous QnA Id. + */ + public void setPreviousQnAId(Integer withPreviousQnAId) { + this.previousQnAId = withPreviousQnAId; + } + + /** + * Gets the previous user query/question. + * + * @return The previous user query. + */ + public String getPreviousUserQuery() { + return this.previousUserQuery; + } + + /** + * Sets the previous user query/question. + * + * @param withPreviousUserQuery The previous user query. + */ + public void setPreviousUserQuery(String withPreviousUserQuery) { + this.previousUserQuery = withPreviousUserQuery; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QnAResponseContext.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QnAResponseContext.java new file mode 100644 index 000000000..53971d832 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QnAResponseContext.java @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.models; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.microsoft.bot.ai.qna.dialogs.QnAMakerPrompt; + +/** + * The context associated with QnA. Used to mark if the qna response has related + * prompts to display. + */ +public class QnAResponseContext { + @JsonProperty("prompts") + private QnAMakerPrompt[] prompts; + + /** + * Gets the prompts collection of related prompts. + * + * @return The QnA prompts array. + */ + public QnAMakerPrompt[] getPrompts() { + return this.prompts; + } + + /** + * Sets the prompts collection of related prompts. + * + * @param withPrompts The QnA prompts array. + */ + public void setPrompts(QnAMakerPrompt[] withPrompts) { + this.prompts = withPrompts; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QueryResult.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QueryResult.java new file mode 100644 index 000000000..4090e4741 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QueryResult.java @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.models; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents an individual result from a knowledge base query. + */ +public class QueryResult { + @JsonProperty("questions") + private String[] questions; + + @JsonProperty("answer") + private String answer; + + @JsonProperty("score") + private Float score; + + @JsonProperty("metadata") + private Metadata[] metadata; + + @JsonProperty("source") + private String source; + + @JsonProperty("id") + private Integer id; + + @JsonProperty("context") + private QnAResponseContext context; + + /** + * Gets the list of questions indexed in the QnA Service for the given answer. + * + * @return The list of questions indexed in the QnA Service for the given + * answer. + */ + public String[] getQuestions() { + return this.questions; + } + + /** + * Sets the list of questions indexed in the QnA Service for the given answer. + * + * @param withQuestions The list of questions indexed in the QnA Service for the + * given answer. + */ + public void setQuestions(String[] withQuestions) { + this.questions = withQuestions; + } + + /** + * Gets the answer text. + * + * @return The answer text. + */ + public String getAnswer() { + return this.answer; + } + + /** + * Sets the answer text. + * + * @param withAnswer The answer text. + */ + public void setAnswer(String withAnswer) { + this.answer = withAnswer; + } + + /** + * Gets the answer's score, from 0.0 (least confidence) to 1.0 (greatest + * confidence). + * + * @return The answer's score, from 0.0 (least confidence) to 1.0 (greatest + * confidence). + */ + public Float getScore() { + return this.score; + } + + /** + * Sets the answer's score, from 0.0 (least confidence) to 1.0 (greatest + * confidence). + * + * @param withScore The answer's score, from 0.0 (least confidence) to 1.0 + * (greatest confidence). + */ + public void setScore(Float withScore) { + this.score = withScore; + } + + /** + * Gets metadata that is associated with the answer. + * + * @return Metadata that is associated with the answer. + */ + public Metadata[] getMetadata() { + return this.metadata; + } + + /** + * Sets metadata that is associated with the answer. + * + * @param withMetadata Metadata that is associated with the answer. + */ + public void setMetadata(Metadata[] withMetadata) { + this.metadata = withMetadata; + } + + /** + * Gets the source from which the QnA was extracted. + * + * @return The source from which the QnA was extracted. + */ + public String getSource() { + return this.source; + } + + /** + * Sets the source from which the QnA was extracted. + * + * @param withSource The source from which the QnA was extracted. + */ + public void setSource(String withSource) { + this.source = withSource; + } + + /** + * Gets the index of the answer in the knowledge base. V3 uses 'qnaId', V4 uses + * 'id'. + * + * @return The index of the answer in the knowledge base. V3 uses 'qnaId', V4 + * uses 'id'. + */ + public Integer getId() { + return this.id; + } + + /** + * Sets the index of the answer in the knowledge base. V3 uses 'qnaId', V4 uses + * 'id'. + * + * @param withId The index of the answer in the knowledge base. V3 uses 'qnaId', + * V4 uses 'id'. + */ + public void setId(Integer withId) { + this.id = withId; + } + + /** + * Gets context for multi-turn responses. + * + * @return The context from which the QnA was extracted. + */ + public QnAResponseContext getContext() { + return this.context; + } + + /** + * Sets context for multi-turn responses. + * + * @param withContext The context from which the QnA was extracted. + */ + public void setContext(QnAResponseContext withContext) { + this.context = withContext; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QueryResults.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QueryResults.java new file mode 100644 index 000000000..84e26c733 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/QueryResults.java @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.models; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Contains answers for a user query. + */ +public class QueryResults { + @JsonProperty("answers") + private QueryResult[] answers; + + @JsonProperty("activeLearningEnabled") + private Boolean activeLearningEnabled; + + /** + * Gets the answers for a user query, sorted in decreasing order of ranking + * score. + * + * @return The answers for a user query, sorted in decreasing order of ranking + * score. + */ + public QueryResult[] getAnswers() { + return this.answers; + } + + /** + * Sets the answers for a user query, sorted in decreasing order of ranking + * score. + * + * @param withAnswers The answers for a user query, sorted in decreasing order + * of ranking score. + */ + public void setAnswers(QueryResult[] withAnswers) { + this.answers = withAnswers; + } + + /** + * Gets a value indicating whether gets or set for the active learning enable + * flag. + * + * @return The active learning enable flag. + */ + public Boolean getActiveLearningEnabled() { + return this.activeLearningEnabled; + } + + /** + * Sets a value indicating whether gets or set for the active learning enable + * flag. + * + * @param withActiveLearningEnabled The active learning enable flag. + */ + public void setActiveLearningEnabled(Boolean withActiveLearningEnabled) { + this.activeLearningEnabled = withActiveLearningEnabled; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/RankerTypes.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/RankerTypes.java new file mode 100644 index 000000000..0cd10e87e --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/RankerTypes.java @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.models; + +/** + * Enumeration of types of ranking. + */ +public final class RankerTypes { + /** + * Default Ranker Behaviour. i.e. Ranking based on Questions and Answer. + */ + public static final String DEFAULT_RANKER_TYPE = "Default"; + + /** + * Ranker based on question Only. + */ + public static final String QUESTION_ONLY = "QuestionOnly"; + + /** + * Ranker based on Autosuggest for question field Only. + */ + public static final String AUTO_SUGGEST_QUESTION = "AutoSuggestQuestion"; + + private RankerTypes() { } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/package-info.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/package-info.java new file mode 100644 index 000000000..cc5be8f6c --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/models/package-info.java @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for +// license information. + +/** + * This package contains the classes for Bot-Builder. + */ +package com.microsoft.bot.ai.qna.models; diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/package-info.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/package-info.java new file mode 100644 index 000000000..6ee0d0731 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/package-info.java @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for +// license information. + +/** + * This package contains the classes for Bot-Builder. + */ +package com.microsoft.bot.ai.qna; diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/ActiveLearningUtils.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/ActiveLearningUtils.java new file mode 100644 index 000000000..726f4cc6d --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/ActiveLearningUtils.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.utils; + +import com.microsoft.bot.ai.qna.models.QueryResult; + +import java.util.ArrayList; +import java.util.List; + +/** + * Active learning helper class. + */ +public final class ActiveLearningUtils { + /** + * Previous Low Score Variation Multiplier.ActiveLearningUtils. + */ + private static final Float PREVIOUS_LOW_SCORE_VARIATION_MULTIPLIER = 0.7f; + + /** + * Max Low Score Variation Multiplier. + */ + private static final Float MAX_LOW_SCORE_VARIATION_MULTIPLIER = 1.0f; + + private static final Integer PERCENTAGE_DIVISOR = 100; + + private static final Float MAXIMUM_SCORE_VARIATION = 95.0F; + + private static final Float MINIMUM_SCORE_VARIATION = 20.0F; + + private static Float maximumScoreForLowScoreVariation = MAXIMUM_SCORE_VARIATION; + + private static Float minimumScoreForLowScoreVariation = MINIMUM_SCORE_VARIATION; + + private ActiveLearningUtils() { } + + /** + * Gets maximum Score For Low Score Variation. + * + * @return Maximum Score For Low Score Variation. + */ + public static Float getMaximumScoreForLowScoreVariation() { + return ActiveLearningUtils.maximumScoreForLowScoreVariation; + } + + /** + * Sets maximum Score For Low Score Variation. + * + * @param withMaximumScoreForLowScoreVariation Maximum Score For Low Score + * Variation. + */ + public static void setMaximumScoreForLowScoreVariation(Float withMaximumScoreForLowScoreVariation) { + ActiveLearningUtils.maximumScoreForLowScoreVariation = withMaximumScoreForLowScoreVariation; + } + + /** + * Gets minimum Score For Low Score Variation. + * + * @return Minimum Score For Low Score Variation. + */ + public static Float getMinimumScoreForLowScoreVariation() { + return ActiveLearningUtils.minimumScoreForLowScoreVariation; + } + + /** + * Sets minimum Score For Low Score Variation. + * + * @param withMinimumScoreForLowScoreVariation Minimum Score For Low Score + * Variation. + */ + public static void setMinimumScoreForLowScoreVariation(Float withMinimumScoreForLowScoreVariation) { + ActiveLearningUtils.minimumScoreForLowScoreVariation = withMinimumScoreForLowScoreVariation; + } + + /** + * Returns list of qnaSearch results which have low score variation. + * + * @param qnaSearchResults List of QnaSearch results. + * @return List of filtered qnaSearch results. + */ + public static List getLowScoreVariation(List qnaSearchResults) { + List filteredQnaSearchResult = new ArrayList(); + + if (qnaSearchResults == null || qnaSearchResults.isEmpty()) { + return filteredQnaSearchResult; + } + + if (qnaSearchResults.size() == 1) { + return qnaSearchResults; + } + + Float topAnswerScore = qnaSearchResults.get(0).getScore() * PERCENTAGE_DIVISOR; + if (topAnswerScore > ActiveLearningUtils.maximumScoreForLowScoreVariation) { + filteredQnaSearchResult.add(qnaSearchResults.get(0)); + return filteredQnaSearchResult; + } + + Float prevScore = topAnswerScore; + + if (topAnswerScore > ActiveLearningUtils.minimumScoreForLowScoreVariation) { + filteredQnaSearchResult.add(qnaSearchResults.get(0)); + + for (int i = 1; i < qnaSearchResults.size(); i++) { + if (ActiveLearningUtils + .includeForClustering(prevScore, qnaSearchResults.get(i).getScore() * PERCENTAGE_DIVISOR, + ActiveLearningUtils.PREVIOUS_LOW_SCORE_VARIATION_MULTIPLIER) + && ActiveLearningUtils.includeForClustering(topAnswerScore, + qnaSearchResults.get(i).getScore() * PERCENTAGE_DIVISOR, + ActiveLearningUtils.MAX_LOW_SCORE_VARIATION_MULTIPLIER)) { + prevScore = qnaSearchResults.get(i).getScore() * PERCENTAGE_DIVISOR; + filteredQnaSearchResult.add(qnaSearchResults.get(i)); + } + } + } + + return filteredQnaSearchResult; + } + + private static Boolean includeForClustering(Float prevScore, Float currentScore, Float multiplier) { + return (prevScore - currentScore) < (multiplier * Math.sqrt(prevScore)); + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/BindToActivity.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/BindToActivity.java new file mode 100644 index 000000000..9126f0064 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/BindToActivity.java @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.utils; + +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.schema.Activity; + +import java.util.concurrent.CompletableFuture; + +import javax.annotation.Nullable; + +/** + * Class to bind activities. + */ +public class BindToActivity { + private Activity activity; + + /** + * Construct to bind an Activity. + * @param withActivity activity to bind. + */ + public BindToActivity(Activity withActivity) { + this.activity = withActivity; + } + + /** + * + * @param context The context. + * @param data The data. + * @return The activity. + */ + public CompletableFuture bind(DialogContext context, @Nullable Object data) { + return CompletableFuture.completedFuture(this.activity); + } + + /** + * Get the activity text. + * @return The activity text. + */ + public String toString() { + return String.format("%s", this.activity.getText()); + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/GenerateAnswerUtils.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/GenerateAnswerUtils.java new file mode 100644 index 000000000..05178903b --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/GenerateAnswerUtils.java @@ -0,0 +1,274 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.utils; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.JsonNode; +import com.microsoft.bot.ai.qna.QnAMaker; +import com.microsoft.bot.ai.qna.QnAMakerEndpoint; +import com.microsoft.bot.ai.qna.QnAMakerOptions; +import com.microsoft.bot.ai.qna.models.Metadata; +import com.microsoft.bot.ai.qna.models.QnAMakerTraceInfo; +import com.microsoft.bot.ai.qna.models.QueryResult; +import com.microsoft.bot.ai.qna.models.QueryResults; +import com.microsoft.bot.ai.qna.models.RankerTypes; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.restclient.serializer.JacksonAdapter; +import com.microsoft.bot.schema.Activity; + +import net.minidev.json.JSONObject; +import org.slf4j.LoggerFactory; + +/** + * Helper class for Generate Answer API. + */ +public class GenerateAnswerUtils { + private QnAMakerEndpoint endpoint; + private QnAMakerOptions options; + + private static final Integer PERCENTAGE_DIVISOR = 100; + private static final Float SCORE_THRESHOLD = 0.3f; + private static final Double TIMEOUT = 100000d; + + /** + * Initializes a new instance of the {@link GenerateAnswerUtils} class. + * + * @param withEndpoint QnA Maker endpoint details. + * @param withOptions QnA Maker options. + */ + public GenerateAnswerUtils(QnAMakerEndpoint withEndpoint, + QnAMakerOptions withOptions) { + this.endpoint = withEndpoint; + + this.options = withOptions != null ? withOptions : new QnAMakerOptions(); + GenerateAnswerUtils.validateOptions(this.options); + } + + /** + * Gets qnA Maker options. + * + * @return The options for QnAMaker. + */ + public QnAMakerOptions getOptions() { + return this.options; + } + + /** + * Sets qnA Maker options. + * + * @param withOptions The options for QnAMaker. + */ + public void setOptions(QnAMakerOptions withOptions) { + this.options = withOptions; + } + + /** + * Generates an answer from the knowledge base. + * + * @param turnContext The Turn Context that contains the user question to be + * queried against your knowledge base. + * @param messageActivity Message activity of the turn context. + * @param withOptions The options for the QnA Maker knowledge base. If null, + * constructor option is used for this instance. + * @return A list of answers for the user query, sorted in decreasing order of + * ranking score. + * @throws IOException IOException + */ + @Deprecated + public CompletableFuture getAnswers(TurnContext turnContext, Activity messageActivity, + QnAMakerOptions withOptions) throws IOException { + return this.getAnswersRaw(turnContext, messageActivity, withOptions).thenApply(result -> result.getAnswers()); + } + + /** + * Generates an answer from the knowledge base. + * + * @param turnContext The Turn Context that contains the user question to be + * queried against your knowledge base. + * @param messageActivity Message activity of the turn context. + * @param withOptions The options for the QnA Maker knowledge base. If null, + * constructor option is used for this instance. + * @return A list of answers for the user query, sorted in decreasing order of + * ranking score. + */ + public CompletableFuture getAnswersRaw(TurnContext turnContext, Activity messageActivity, + QnAMakerOptions withOptions) { + if (turnContext == null) { + throw new IllegalArgumentException("turnContext"); + } + + if (turnContext.getActivity() == null) { + throw new IllegalArgumentException(String.format("The %1$s property for %2$s can't be null: turnContext", + turnContext.getActivity(), "turnContext")); + } + + if (messageActivity == null) { + throw new IllegalArgumentException("Activity type is not a message"); + } + + QnAMakerOptions hydratedOptions = this.hydrateOptions(withOptions); + GenerateAnswerUtils.validateOptions(hydratedOptions); + + try { + return this.queryQnaService(messageActivity, hydratedOptions).thenCompose(result -> { + this.emitTraceInfo(turnContext, messageActivity, result.getAnswers(), hydratedOptions); + return CompletableFuture.completedFuture(result); + }); + } catch (IOException e) { + LoggerFactory.getLogger(GenerateAnswerUtils.class).error("getAnswersRaw"); + return CompletableFuture.completedFuture(null); + } + } + + private static CompletableFuture formatQnAResult(JsonNode response, QnAMakerOptions options) + throws IOException { + String jsonResponse = null; + JacksonAdapter jacksonAdapter = new JacksonAdapter(); + QueryResults results = null; + + jsonResponse = response.toString(); + results = jacksonAdapter.deserialize(jsonResponse, QueryResults.class); + for (QueryResult answer : results.getAnswers()) { + answer.setScore(answer.getScore() / PERCENTAGE_DIVISOR); + } + List answerList = Arrays.asList(results.getAnswers()). + stream().filter(answer -> answer.getScore() > options.getScoreThreshold()).collect(Collectors.toList()); + results.setAnswers(answerList.toArray(new QueryResult[answerList.size()])); + + return CompletableFuture.completedFuture(results); + } + + private static void validateOptions(QnAMakerOptions options) { + if (options.getScoreThreshold() == 0) { + options.setScoreThreshold(SCORE_THRESHOLD); + } + + if (options.getTop() == 0) { + options.setTop(1); + } + + if (options.getScoreThreshold() < 0 || options.getScoreThreshold() > 1) { + throw new IllegalArgumentException(String + .format("options: The %s property should be a value between 0 and 1", options.getScoreThreshold())); + } + + if (options.getTimeout() == 0.0d) { + options.setTimeout(TIMEOUT); + } + + if (options.getTop() < 1) { + throw new IllegalArgumentException("options: The top property should be an integer greater than 0"); + } + + if (options.getStrictFilters() == null) { + options.setStrictFilters(new Metadata[0]); + } + + if (options.getRankerType() == null) { + options.setRankerType(RankerTypes.DEFAULT_RANKER_TYPE); + } + } + + /** + * Combines QnAMakerOptions passed into the QnAMaker constructor with the + * options passed as arguments into GetAnswersAsync(). + * + * @param queryOptions The options for the QnA Maker knowledge base. + * @return Return modified options for the QnA Maker knowledge base. + */ + private QnAMakerOptions hydrateOptions(QnAMakerOptions queryOptions) { + JacksonAdapter jacksonAdapter = new JacksonAdapter(); + QnAMakerOptions hydratedOptions = null; + + try { + hydratedOptions = jacksonAdapter.deserialize(jacksonAdapter.serialize(options), + QnAMakerOptions.class); + } catch (IOException e) { + LoggerFactory.getLogger(GenerateAnswerUtils.class).error("hydrateOptions"); + } + + if (queryOptions != null) { + if (queryOptions.getScoreThreshold() != hydratedOptions.getScoreThreshold() + && queryOptions.getScoreThreshold() != 0) { + hydratedOptions.setScoreThreshold(queryOptions.getScoreThreshold()); + } + + if (queryOptions.getTop() != hydratedOptions.getTop() && queryOptions.getTop() != 0) { + hydratedOptions.setTop(queryOptions.getTop()); + } + + if (queryOptions.getStrictFilters() != null && queryOptions.getStrictFilters().length > 0) { + hydratedOptions.setStrictFilters(queryOptions.getStrictFilters()); + } + + hydratedOptions.setContext(queryOptions.getContext()); + hydratedOptions.setQnAId(queryOptions.getQnAId()); + hydratedOptions.setIsTest(queryOptions.getIsTest()); + hydratedOptions.setRankerType(queryOptions.getRankerType() != null ? queryOptions.getRankerType() + : RankerTypes.DEFAULT_RANKER_TYPE); + hydratedOptions.setStrictFiltersJoinOperator(queryOptions.getStrictFiltersJoinOperator()); + } + + return hydratedOptions; + } + + private CompletableFuture queryQnaService(Activity messageActivity, QnAMakerOptions withOptions) + throws IOException { + String requestUrl = String.format("%1$s/knowledgebases/%2$s/generateanswer", this.endpoint.getHost(), + this.endpoint.getKnowledgeBaseId()); + JacksonAdapter jacksonAdapter = new JacksonAdapter(); + String jsonRequest = null; + + jsonRequest = jacksonAdapter.serialize(new JSONObject() { + { + put("question", messageActivity.getText()); + put("top", withOptions.getTop()); + put("strictFilters", withOptions.getStrictFilters()); + put("scoreThreshold", withOptions.getScoreThreshold()); + put("context", withOptions.getContext()); + put("qnaId", withOptions.getQnAId()); + put("isTest", withOptions.getIsTest()); + put("rankerType", withOptions.getRankerType()); + put("StrictFiltersCompoundOperationType", withOptions.getStrictFiltersJoinOperator()); + } + }); + + HttpRequestUtils httpRequestHelper = new HttpRequestUtils(); + return httpRequestHelper.executeHttpRequest(requestUrl, jsonRequest, this.endpoint).thenCompose(response -> { + try { + return GenerateAnswerUtils.formatQnAResult(response, withOptions); + } catch (IOException e) { + LoggerFactory.getLogger(GenerateAnswerUtils.class).error("QueryQnAService", e); + return CompletableFuture.completedFuture(null); + } + }); + } + + private CompletableFuture emitTraceInfo(TurnContext turnContext, Activity messageActivity, + QueryResult[] result, QnAMakerOptions withOptions) { + String knowledgeBaseId = this.endpoint.getKnowledgeBaseId(); + QnAMakerTraceInfo traceInfo = new QnAMakerTraceInfo() { + { + setMessage(messageActivity); + setQueryResults(result); + setKnowledgeBaseId(knowledgeBaseId); + setScoreThreshold(withOptions.getScoreThreshold()); + setTop(withOptions.getTop()); + setStrictFilters(withOptions.getStrictFilters()); + setContext(withOptions.getContext()); + setQnAId(withOptions.getQnAId()); + setIsTest(withOptions.getIsTest()); + setRankerType(withOptions.getRankerType()); + } + }; + Activity traceActivity = Activity.createTraceActivity(QnAMaker.QNA_MAKER_NAME, QnAMaker.QNA_MAKER_TRACE_TYPE, + traceInfo, QnAMaker.QNA_MAKER_TRACE_LABEL); + return turnContext.sendActivity(traceActivity).thenApply(response -> null); + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/HttpRequestUtils.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/HttpRequestUtils.java new file mode 100644 index 000000000..dcfff3e59 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/HttpRequestUtils.java @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.utils; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.bot.ai.qna.QnAMakerEndpoint; +import com.microsoft.bot.connector.UserAgent; + +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.slf4j.LoggerFactory; + +/** + * Helper for HTTP requests. + */ +public class HttpRequestUtils { + private OkHttpClient httpClient = new OkHttpClient(); + /** + * Execute Http request. + * + * @param requestUrl Http request url. + * @param payloadBody Http request body. + * @param endpoint QnA Maker endpoint details. + * @return Returns http response object. + */ + public CompletableFuture executeHttpRequest(String requestUrl, String payloadBody, + QnAMakerEndpoint endpoint) { + if (requestUrl == null) { + throw new IllegalArgumentException("requestUrl: Request url can not be null."); + } + + if (payloadBody == null) { + throw new IllegalArgumentException("payloadBody: Payload body can not be null."); + } + + if (endpoint == null) { + throw new IllegalArgumentException("endpoint"); + } + + ObjectMapper mapper = new ObjectMapper(); + String endpointKey = endpoint.getEndpointKey(); + Response response; + JsonNode qnaResponse = null; + try { + Request request = buildRequest(requestUrl, endpointKey, buildRequestBody(payloadBody)); + response = this.httpClient.newCall(request).execute(); + qnaResponse = mapper.readTree(response.body().string()); + if (!response.isSuccessful()) { + String message = "Unexpected code " + response.code(); + throw new Exception(message); + } + } catch (Exception e) { + LoggerFactory.getLogger(HttpRequestUtils.class).error("findPackages", e); + throw new CompletionException(e); + } + + return CompletableFuture.completedFuture(qnaResponse); + } + + private Request buildRequest(String requestUrl, String endpointKey, RequestBody body) { + HttpUrl.Builder httpBuilder = HttpUrl.parse(requestUrl).newBuilder(); + Request.Builder requestBuilder = new Request.Builder() + .url(httpBuilder.build()) + .addHeader("Authorization", String.format("EndpointKey %s", endpointKey)) + .addHeader("Ocp-Apim-Subscription-Key", endpointKey).addHeader("User-Agent", UserAgent.value()) + .post(body); + return requestBuilder.build(); + } + + private RequestBody buildRequestBody(String payloadBody) throws JsonProcessingException { + return RequestBody.create(payloadBody, MediaType.parse("application/json; charset=utf-8")); + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/QnACardBuilder.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/QnACardBuilder.java new file mode 100644 index 000000000..c2b7bb110 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/QnACardBuilder.java @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.utils; + +import java.util.ArrayList; +import java.util.List; + +import com.microsoft.bot.ai.qna.dialogs.QnAMakerPrompt; +import com.microsoft.bot.ai.qna.models.QueryResult; +import com.microsoft.bot.schema.ActionTypes; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.Attachment; +import com.microsoft.bot.schema.CardAction; +import com.microsoft.bot.schema.HeroCard; + +/** + * Message activity card builder for QnAMaker dialogs. + */ +public final class QnACardBuilder { + + private QnACardBuilder() { } + + /** + * Get active learning suggestions card. + * + * @param suggestionsList List of suggested questions. + * @param cardTitle Title of the cards + * @param cardNoMatchText No match text. + * @return Activity. + */ + public static Activity getSuggestionsCard(List suggestionsList, String cardTitle, String cardNoMatchText) { + if (suggestionsList == null) { + throw new IllegalArgumentException("suggestionsList"); + } + + if (cardTitle == null) { + throw new IllegalArgumentException("cardTitle"); + } + + if (cardNoMatchText == null) { + throw new IllegalArgumentException("cardNoMatchText"); + } + + Activity chatActivity = Activity.createMessageActivity(); + chatActivity.setText(cardTitle); + List buttonList = new ArrayList(); + + // Add all suggestions + for (String suggestion : suggestionsList) { + buttonList.add(new CardAction() { + { + setValue(suggestion); + setType(ActionTypes.IM_BACK); + setTitle(suggestion); + } + }); + } + + // Add No match text + buttonList.add(new CardAction() { + { + setValue(cardNoMatchText); + setType(ActionTypes.IM_BACK); + setTitle(cardNoMatchText); + } + }); + + HeroCard plCard = new HeroCard() { + { + setButtons(buttonList); + } + }; + + // Create the attachment. + Attachment attachment = plCard.toAttachment(); + + chatActivity.setAttachment(attachment); + + return chatActivity; + } + + /** + * Get active learning suggestions card. + * + * @param result Result to be dispalyed as prompts. + * @param cardNoMatchText No match text. + * @return Activity. + */ + public static Activity getQnAPromptsCard(QueryResult result, String cardNoMatchText) { + if (result == null) { + throw new IllegalArgumentException("result"); + } + + if (cardNoMatchText == null) { + throw new IllegalArgumentException("cardNoMatchText"); + } + + Activity chatActivity = Activity.createMessageActivity(); + chatActivity.setText(result.getAnswer()); + List buttonList = new ArrayList(); + + // Add all prompt + for (QnAMakerPrompt prompt : result.getContext().getPrompts()) { + buttonList.add(new CardAction() { + { + setValue(prompt.getDisplayText()); + setType(ActionTypes.IM_BACK); + setTitle(prompt.getDisplayText()); + } + }); + } + + HeroCard plCard = new HeroCard() { + { + setButtons(buttonList); + } + }; + + // Create the attachment. + Attachment attachment = plCard.toAttachment(); + + chatActivity.setAttachment(attachment); + + return chatActivity; + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/QnATelemetryConstants.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/QnATelemetryConstants.java new file mode 100644 index 000000000..0fad35865 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/QnATelemetryConstants.java @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.utils; + +/** + * Default QnA event and property names logged using IBotTelemetryClient. + */ +public final class QnATelemetryConstants { + + private QnATelemetryConstants() { } + + /** + * The Key used for the custom event type within telemetry. + */ + public static final String QNA_MSG_EVENT = "QnaMessage"; // Event name + + /** + * The Key used when storing a QnA Knowledge Base ID in a custom event within + * telemetry. + */ + public static final String KNOWLEDGE_BASE_ID_PROPERTY = "knowledgeBaseId"; + + /** + * The Key used when storing a QnA Answer in a custom event within telemetry. + */ + public static final String ANSWER_PROPERTY = "answer"; + + /** + * The Key used when storing a flag indicating if a QnA article was found in a + * custom event within telemetry. + */ + public static final String ARTICLE_FOUND_PROPERTY = "articleFound"; + + /** + * The Key used when storing the Channel ID in a custom event within telemetry. + */ + public static final String CHANNEL_ID_PROPERTY = "channelId"; + + /** + * The Key used when storing a matched question ID in a custom event within + * telemetry. + */ + public static final String MATCHED_QUESTION_PROPERTY = "matchedQuestion"; + + /** + * The Key used when storing the identified question text in a custom event + * within telemetry. + */ + public static final String QUESTION_PROPERTY = "question"; + + /** + * The Key used when storing the identified question ID in a custom event within + * telemetry. + */ + public static final String QUESTION_ID_PROPERTY = "questionId"; + + /** + * The Key used when storing a QnA Maker result score in a custom event within + * telemetry. + */ + public static final String SCORE_PROPERTY = "score"; + + /** + * The Key used when storing a username in a custom event within telemetry. + */ + public static final String USERNAME_PROPERTY = "username"; +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/TrainUtils.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/TrainUtils.java new file mode 100644 index 000000000..d7fa020cc --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/TrainUtils.java @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna.utils; + +import com.microsoft.bot.ai.qna.QnAMakerEndpoint; +import com.microsoft.bot.ai.qna.models.FeedbackRecords; +import com.microsoft.bot.restclient.serializer.JacksonAdapter; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; + +/** + * Helper class for train API. + */ +public class TrainUtils { + private QnAMakerEndpoint endpoint; + + /** + * Initializes a new instance of the {@link TrainUtils} class. + * + * @param withEndpoint QnA Maker endpoint details. + */ + public TrainUtils(QnAMakerEndpoint withEndpoint) { + this.endpoint = withEndpoint; + } + + /** + * Train API to provide feedback. + * + * @param feedbackRecords Feedback record list. + * @return A Task representing the asynchronous operation. + * @throws IOException IOException + */ + public CompletableFuture callTrain(FeedbackRecords feedbackRecords) throws IOException { + if (feedbackRecords == null) { + throw new IllegalArgumentException("feedbackRecords: Feedback records cannot be null."); + } + + if (feedbackRecords.getRecords() == null || feedbackRecords.getRecords().length == 0) { + return CompletableFuture.completedFuture(null); + } + + // Call train + return this.queryTrain(feedbackRecords); + } + + private CompletableFuture queryTrain(FeedbackRecords feedbackRecords) throws IOException { + String requestUrl = String.format("%1$s/knowledgebases/%2$s/train", this.endpoint.getHost(), + this.endpoint.getKnowledgeBaseId()); + + JacksonAdapter jacksonAdapter = new JacksonAdapter(); + String jsonRequest = jacksonAdapter.serialize(feedbackRecords); + + HttpRequestUtils httpRequestHelper = new HttpRequestUtils(); + return httpRequestHelper.executeHttpRequest(requestUrl, jsonRequest, this.endpoint).thenApply(result -> null); + } +} diff --git a/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/package-info.java b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/package-info.java new file mode 100644 index 000000000..145e9ebb8 --- /dev/null +++ b/libraries/bot-ai-qna/src/main/java/com/microsoft/bot/ai/qna/utils/package-info.java @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for +// license information. + +/** + * This package contains the classes for Bot-Builder. + */ +package com.microsoft.bot.ai.qna.utils; diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/MyTurnContext.java b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/MyTurnContext.java new file mode 100644 index 000000000..872955189 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/MyTurnContext.java @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import com.microsoft.bot.builder.BotAdapter; +import com.microsoft.bot.builder.DeleteActivityHandler; +import com.microsoft.bot.builder.SendActivitiesHandler; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.TurnContextStateCollection; +import com.microsoft.bot.builder.UpdateActivityHandler; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ConversationReference; +import com.microsoft.bot.schema.InputHints; +import com.microsoft.bot.schema.ResourceResponse; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public class MyTurnContext implements TurnContext { + + private BotAdapter adapter; + private Activity activity; + + public MyTurnContext(BotAdapter withAdapter, Activity withActivity) { + this.adapter = withAdapter; + this.activity = withActivity; + } + + public String getLocale() { + throw new UnsupportedOperationException(); + } + + public void setLocale(String withLocale) { + throw new UnsupportedOperationException(); + } + + public BotAdapter getAdapter() { + return adapter; + } + + public Activity getActivity() { + return activity; + } + + public TurnContextStateCollection getTurnState() { + throw new UnsupportedOperationException(); + } + + public boolean getResponded() { + throw new UnsupportedOperationException(); + } + + public CompletableFuture deleteActivity(String activityId) { + throw new UnsupportedOperationException(); + } + + public CompletableFuture deleteActivity(ConversationReference conversationReference) { + throw new UnsupportedOperationException(); + } + + public TurnContext onDeleteActivity(DeleteActivityHandler handler) { + throw new UnsupportedOperationException(); + } + + public TurnContext onSendActivities(SendActivitiesHandler handler) { + throw new UnsupportedOperationException(); + } + + public TurnContext onUpdateActivity(UpdateActivityHandler handler) { + throw new UnsupportedOperationException(); + } + + public CompletableFuture sendActivities(List activities) { + throw new UnsupportedOperationException(); + } + + public CompletableFuture sendActivity(String textReplyToSend, String speak, + InputHints inputHint) { + inputHint = inputHint != null ? inputHint : InputHints.ACCEPTING_INPUT; + throw new UnsupportedOperationException(); + } + + public CompletableFuture sendActivity(Activity activity) { + throw new UnsupportedOperationException(); + } + + public CompletableFuture sendActivity(String textToReply) { + throw new UnsupportedOperationException(); + } + + public CompletableFuture sendActivity(String textReplyToSend, String speak) { + throw new UnsupportedOperationException(); + } + + public CompletableFuture updateActivity(Activity activity) { + throw new UnsupportedOperationException(); + } + +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerCardEqualityComparer.java b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerCardEqualityComparer.java new file mode 100644 index 000000000..81d40abd1 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerCardEqualityComparer.java @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; + +public class QnAMakerCardEqualityComparer { + + public Boolean Equals(Activity x, Activity y) { + if (x == null && y == null) { + return true; + } + + if(x == null || y == null) { + return false; + } + + if(x.isType(ActivityTypes.MESSAGE) && y.isType(ActivityTypes.MESSAGE)) { + Activity activity1 = x; + Activity activity2 = y; + + if (activity1 == null || activity2 == null) { + return false; + } + + // Check for attachments + if (activity1.getAttachments() != null && activity2.getAttachments() != null) { + if(activity1.getAttachments().size() != activity2.getAttachments().size()) { + return false; + } + } + return true; + } + + return false; + } + + public Integer getHashCode(Activity obj) { + return obj.getId().hashCode(); + } + +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerRecognizerTests.java b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerRecognizerTests.java new file mode 100644 index 000000000..cdb2f31d4 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerRecognizerTests.java @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.bot.builder.RecognizerResult; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.TurnContextImpl; +import com.microsoft.bot.builder.adapters.TestAdapter; +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.dialogs.DialogSet; +import com.microsoft.bot.dialogs.DialogState; +import com.microsoft.bot.schema.Activity; + +import okhttp3.HttpUrl; +import org.apache.commons.io.FileUtils; +import org.junit.Assert; +import org.junit.Test; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +import static org.junit.Assert.fail; + +public class QnAMakerRecognizerTests { + private final String knowledgeBaseId = "dummy-id"; + private final String endpointKey = "dummy-key"; + private final String hostname = "http://localhost"; + private final Boolean mockQnAResponse = true; + + @Test + public void logPiiIsFalseByDefault() { + QnAMakerRecognizer recognizer = new QnAMakerRecognizer() { + { + setHostName(hostname); + setEndpointKey(endpointKey); + setKnowledgeBaseId(knowledgeBaseId); + } + }; + Boolean logPersonalInfo = recognizer.getLogPersonalInformation(); + // Should be false by default, when not specified by user. + Assert.assertFalse(logPersonalInfo); + } + + @Test + public void noTextNoAnswer() { + Activity activity = Activity.createMessageActivity(); + TurnContext context = new TurnContextImpl(new TestAdapter(), activity); + DialogContext dc = new DialogContext(new DialogSet(), context, new DialogState()); + QnAMakerRecognizer recognizer = new QnAMakerRecognizer() { + { + setHostName(hostname); + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + } + }; + RecognizerResult result = recognizer.recognize(dc, activity).join(); + Assert.assertEquals(result.getEntities(), null); + Assert.assertEquals(result.getProperties().get("answers"), null); + Assert.assertEquals(result.getIntents().get("QnAMatch"), null); + Assert.assertNotEquals(result.getIntents().get("None"), null); + } + + @Test + public void noAnswer() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsNoAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + // Set mock response in MockWebServer + String url = "/qnamaker/knowledgebases/"; + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer,response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerRecognizer recognizer = new QnAMakerRecognizer() { + { + setHostName(finalEndpoint); + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + } + }; + Activity activity = Activity.createMessageActivity(); + activity.setText("test"); + TurnContext context = new TurnContextImpl(new TestAdapter(), activity); + DialogContext dc = new DialogContext(new DialogSet(), context, new DialogState()); + RecognizerResult result = recognizer.recognize(dc, activity).join(); + Assert.assertEquals(result.getEntities(), null); + Assert.assertEquals(result.getProperties().get("answers"), null); + Assert.assertEquals(result.getIntents().get("QnAMatch"), null); + Assert.assertNotEquals(result.getIntents().get("None"), null); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void returnAnswers() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + // Set mock response in MockWebServer + String url = "/qnamaker/knowledgebases/"; + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer,response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerRecognizer recognizer = new QnAMakerRecognizer() { + { + setHostName(finalEndpoint); + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + } + }; + Activity activity = Activity.createMessageActivity(); + activity.setText("test"); + TurnContext context = new TurnContextImpl(new TestAdapter(), activity); + DialogContext dc = new DialogContext(new DialogSet(), context, new DialogState()); + RecognizerResult result = recognizer.recognize(dc, activity).join(); + validateAnswers(result); + Assert.assertEquals(result.getIntents().get("None"), null); + Assert.assertNotEquals(result.getIntents().get("QnAMatch"), null); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void topNAnswers() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_TopNAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + // Set mock response in MockWebServer + String url = "/qnamaker/knowledgebases/"; + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer,response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerRecognizer recognizer = new QnAMakerRecognizer() { + { + setHostName(finalEndpoint); + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + } + }; + Activity activity = Activity.createMessageActivity(); + activity.setText("test"); + TurnContext context = new TurnContextImpl(new TestAdapter(), activity); + DialogContext dc = new DialogContext(new DialogSet(), context, new DialogState()); + RecognizerResult result = recognizer.recognize(dc, activity).join(); + validateAnswers(result); + Assert.assertEquals(result.getIntents().get("None"), null); + Assert.assertNotEquals(result.getIntents().get("QnAMatch"), null); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void returnAnswersWithIntents() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswerWithIntent.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + // Set mock response in MockWebServer + String url = "/qnamaker/knowledgebases/"; + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer,response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerRecognizer recognizer = new QnAMakerRecognizer() { + { + setHostName(finalEndpoint); + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + } + }; + Activity activity = Activity.createMessageActivity(); + activity.setText("test"); + TurnContext context = new TurnContextImpl(new TestAdapter(), activity); + DialogContext dc = new DialogContext(new DialogSet(), context, new DialogState()); + RecognizerResult result = recognizer.recognize(dc, activity).join(); + validateAnswers(result); + Assert.assertEquals(result.getIntents().get("None"), null); + Assert.assertNotEquals(result.getIntents().get("DeferToRecognizer_xxx"), null); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + private String readFileContent (String fileName) throws IOException { + String path = Paths.get("", "src", "test", "java", "com", "microsoft", "bot", "ai", "qna", + "testData", fileName).toAbsolutePath().toString(); + File file = new File(path); + return FileUtils.readFileToString(file, "utf-8"); + } + + private HttpUrl initializeMockServer(MockWebServer mockWebServer, JsonNode response, String url) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + String mockResponse = mapper.writeValueAsString(response); + mockWebServer.enqueue(new MockResponse() + .addHeader("Content-Type", "application/json; charset=utf-8") + .setBody(mockResponse)); + + mockWebServer.start(); + return mockWebServer.url(url); + } + + private void validateAnswers(RecognizerResult result) { + Assert.assertNotEquals(result.getProperties().get("answers"), null); + Assert.assertEquals(result.getEntities().get("answer").size(), 1); + Assert.assertEquals(result.getEntities().get("$instance").get("answer").get(0).get("startIndex").asInt(), 0); + Assert.assertTrue(result.getEntities().get("$instance").get("answer").get(0).get("endIndex") != null); + } +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerTests.java b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerTests.java new file mode 100644 index 000000000..160b8ce78 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerTests.java @@ -0,0 +1,2178 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.bot.ai.qna.dialogs.QnAMakerDialog; +import com.microsoft.bot.ai.qna.models.FeedbackRecord; +import com.microsoft.bot.ai.qna.models.FeedbackRecords; +import com.microsoft.bot.ai.qna.models.Metadata; +import com.microsoft.bot.ai.qna.models.QnAMakerTraceInfo; +import com.microsoft.bot.ai.qna.models.QnARequestContext; +import com.microsoft.bot.ai.qna.models.QueryResult; +import com.microsoft.bot.ai.qna.models.QueryResults; +import com.microsoft.bot.ai.qna.utils.QnATelemetryConstants; +import com.microsoft.bot.builder.BotTelemetryClient; +import com.microsoft.bot.builder.ConversationState; +import com.microsoft.bot.builder.MemoryStorage; +import com.microsoft.bot.builder.MemoryTranscriptStore; +import com.microsoft.bot.builder.PagedResult; +import com.microsoft.bot.builder.Storage; +import com.microsoft.bot.builder.TraceTranscriptLogger; +import com.microsoft.bot.builder.TranscriptLoggerMiddleware; +import com.microsoft.bot.builder.TurnContext; +import com.microsoft.bot.builder.TurnContextImpl; +import com.microsoft.bot.builder.UserState; +import com.microsoft.bot.builder.adapters.TestAdapter; +import com.microsoft.bot.builder.adapters.TestFlow; + +import com.microsoft.bot.dialogs.ComponentDialog; +import com.microsoft.bot.dialogs.Dialog; +import com.microsoft.bot.dialogs.DialogContext; +import com.microsoft.bot.dialogs.DialogDependencies; +import com.microsoft.bot.dialogs.DialogManager; +import com.microsoft.bot.dialogs.DialogReason; +import com.microsoft.bot.dialogs.DialogTurnResult; +import com.microsoft.bot.schema.Activity; +import com.microsoft.bot.schema.ActivityTypes; +import com.microsoft.bot.schema.ChannelAccount; +import com.microsoft.bot.schema.ConversationAccount; + +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import org.slf4j.LoggerFactory; + +import okhttp3.OkHttpClient; + +import static org.junit.Assert.fail; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@RunWith(MockitoJUnitRunner.class) +public class QnAMakerTests { + private final String knowledgeBaseId = "dummy-id"; + private final String endpointKey = "dummy-key"; + private final String hostname = "http://localhost"; + private final Boolean mockQnAResponse = true; + + @Captor + ArgumentCaptor eventNameCaptor; + + @Captor + ArgumentCaptor> propertiesCaptor; + + @Captor + ArgumentCaptor> metricsCaptor; + + private String getRequestUrl() { + return String.format("/qnamaker/knowledgebases/%s/generateanswer", knowledgeBaseId); + } + + private String getV2LegacyRequestUrl() { + return String.format("/qnamaker/v2.0/knowledgebases/%s/generateanswer", knowledgeBaseId); + } + + private String getV3LegacyRequestUrl() { + return String.format("/qnamaker/v3.0/knowledgebases/%s/generateanswer", knowledgeBaseId); + } + + private String getTrainRequestUrl() { + return String.format("/qnamaker/v3.0/knowledgebases/%s/train", knowledgeBaseId); + } + + @Test + public void qnaMakerTraceActivity() { + MockWebServer mockWebServer = new MockWebServer(); + try { + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + + // Invoke flow which uses mock + MemoryTranscriptStore transcriptStore = new MemoryTranscriptStore(); + TestAdapter adapter = new TestAdapter( + TestAdapter.createConversationReference("QnaMaker_TraceActivity", "User1", "Bot")) + .use(new TranscriptLoggerMiddleware(transcriptStore)); + final String[] conversationId = {null}; + new TestFlow(adapter, turnContext -> { + // Simulate Qna Lookup + if(turnContext.getActivity().getText().compareTo("how do I clean the stove?") == 0) { + QueryResult[] results = qna.getAnswers(turnContext, null).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", results[0].getAnswer()); + } + + conversationId[0] = turnContext.getActivity().getConversation().getId(); + Activity typingActivity = new Activity() { + { + setType(ActivityTypes.TYPING); + setRelatesTo(turnContext.getActivity().getRelatesTo()); + } + }; + turnContext.sendActivity(typingActivity).join(); + try { + TimeUnit.SECONDS.sleep(5); + } catch (InterruptedException e) { + // Empty error + } + turnContext.sendActivity(String.format("echo:%s", turnContext.getActivity().getText())).join(); + return CompletableFuture.completedFuture(null); + }) + .send("how do I clean the stove?") + .assertReply(activity -> { + Assert.assertTrue(activity.isType(ActivityTypes.TYPING)); + }) + .assertReply("echo:how do I clean the stove?") + .send("bar") + .assertReply(activity -> Assert.assertTrue(activity.isType(ActivityTypes.TYPING))) + .assertReply("echo:bar") + .startTest().join(); + + // Validate Trace Activity created + PagedResult pagedResult = transcriptStore.getTranscriptActivities("test", conversationId[0]).join(); + Assert.assertEquals(7, pagedResult.getItems().size()); + Assert.assertEquals("how do I clean the stove?", pagedResult.getItems().get(0).getText()); + Assert.assertTrue(pagedResult.getItems().get(1).isType(ActivityTypes.TRACE)); + QnAMakerTraceInfo traceInfo = (QnAMakerTraceInfo) pagedResult.getItems().get(1).getValue(); + Assert.assertNotNull(traceInfo); + Assert.assertEquals("echo:how do I clean the stove?", pagedResult.getItems().get(3).getText()); + Assert.assertEquals("bar", pagedResult.getItems().get(4).getText()); + Assert.assertEquals("echo:bar", pagedResult.getItems().get(6).getText()); + for (Activity activity : pagedResult.getItems()) { + Assert.assertFalse(StringUtils.isBlank(activity.getId())); + } + } catch(Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTraceActivityEmptyText() { + MockWebServer mockWebServer = new MockWebServer(); + try { + // Get basic Qna + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + + // No text + TestAdapter adapter = new TestAdapter( + TestAdapter.createConversationReference("QnaMaker_TraceActivity_EmptyText", "User1", "Bot")); + Activity activity = new Activity() { + { + setType(ActivityTypes.MESSAGE); + setText(new String()); + setConversation(new ConversationAccount()); + setRecipient(new ChannelAccount()); + setFrom(new ChannelAccount()); + } + }; + TurnContext context = new TurnContextImpl(adapter, activity); + Assert.assertThrows(IllegalArgumentException.class, () -> qna.getAnswers(context, null)); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTraceActivityNullText() { + MockWebServer mockWebServer = new MockWebServer(); + try { + // Get basic Qna + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + + // No text + TestAdapter adapter = new TestAdapter( + TestAdapter.createConversationReference("QnaMaker_TraceActivity_NullText", "User1", "Bot")); + Activity activity = new Activity() { + { + setType(ActivityTypes.MESSAGE); + setText(null); + setConversation(new ConversationAccount()); + setRecipient(new ChannelAccount()); + setFrom(new ChannelAccount()); + } + }; + TurnContext context = new TurnContextImpl(adapter, activity); + Assert.assertThrows(IllegalArgumentException.class, () -> qna.getAnswers(context, null)); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTraceActivityNullContext() { + MockWebServer mockWebServer = new MockWebServer(); + try { + // Get basic Qna + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + + Assert.assertThrows(IllegalArgumentException.class, () -> qna.getAnswers(null, null)); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTraceActivityBadMessage() { + MockWebServer mockWebServer = new MockWebServer(); + try { + // Get basic Qna + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + + // No text + TestAdapter adapter = new TestAdapter( + TestAdapter.createConversationReference("QnaMaker_TraceActivity_BadMessage", "User1", "Bot")); + Activity activity = new Activity() { + { + setType(ActivityTypes.TRACE); + setText("My Text"); + setConversation(new ConversationAccount()); + setRecipient(new ChannelAccount()); + setFrom(new ChannelAccount()); + } + }; + + TurnContext context = new TurnContextImpl(adapter, activity); + Assert.assertThrows(IllegalArgumentException.class, () -> qna.getAnswers(context, null)); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTraceActivityNullActivity() { + MockWebServer mockWebServer = new MockWebServer(); + try { + // Get basic Qna + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + + // No text + TestAdapter adapter = new TestAdapter( + TestAdapter.createConversationReference("QnaMaker_TraceActivity_NullActivity", "User1", "Bot")); + TurnContext context = new MyTurnContext(adapter, null); + Assert.assertThrows(IllegalArgumentException.class, () -> qna.getAnswers(context, null)); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerReturnsAnswer() { + MockWebServer mockWebServer = new MockWebServer(); + try { + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", results[0].getAnswer()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerReturnsAnswerRaw() { + MockWebServer mockWebServer = new MockWebServer(); + try { + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + QueryResults results = qna.getAnswersRaw(getContext("how do I clean the stove?"), options, null, null).join(); + Assert.assertNotNull(results.getAnswers()); + Assert.assertTrue(results.getActiveLearningEnabled()); + Assert.assertTrue(results.getAnswers().length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + results.getAnswers()[0].getAnswer()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerLowScoreVariation() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_TopNAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnaMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions qnaMakerOptions = new QnAMakerOptions() { + { + setTop(5); + } + }; + QnAMaker qna = new QnAMaker(qnaMakerEndpoint, qnaMakerOptions); + QueryResult[] results = qna.getAnswers(getContext("Q11"), null).join(); + Assert.assertNotNull(results); + Assert.assertEquals(4, results.length); + + QueryResult[] filteredResults = qna.getLowScoreVariation(results); + Assert.assertNotNull(filteredResults); + Assert.assertEquals(3, filteredResults.length); + + String content2 = readFileContent("QnaMaker_TopNAnswer_DisableActiveLearning.json"); + JsonNode response2 = mapper.readTree(content2); + this.initializeMockServer(mockWebServer, response2, this.getRequestUrl()); + QueryResult[] results2 = qna.getAnswers(getContext("Q11"), null).join(); + Assert.assertNotNull(results2); + Assert.assertEquals(4, results2.length); + + QueryResult[] filteredResults2 = qna.getLowScoreVariation(results2); + Assert.assertNotNull(filteredResults2); + Assert.assertEquals(3, filteredResults2.length); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerCallTrain() { + MockWebServer mockWebServer = new MockWebServer(); + ObjectMapper objectMapper = new ObjectMapper(); + String url = this.getTrainRequestUrl(); + String endpoint = ""; + try { + JsonNode response = objectMapper.readTree("{}"); + endpoint = String.format( + "%s:%s", + hostname, + initializeMockServer( + mockWebServer, + response, + url).port()); + String finalEndpoint = endpoint; + QnAMakerEndpoint qnaMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMaker qna = new QnAMaker(qnaMakerEndpoint, null); + FeedbackRecords feedbackRecords = new FeedbackRecords(); + + FeedbackRecord feedback1 = new FeedbackRecord() { + { + setQnaId(1); + setUserId("test"); + setUserQuestion("How are you?"); + } + }; + + FeedbackRecord feedback2 = new FeedbackRecord() { + { + setQnaId(2); + setUserId("test"); + setUserQuestion("What up??"); + } + }; + + feedbackRecords.setRecords(new FeedbackRecord[] { feedback1, feedback2 }); + qna.callTrain(feedbackRecords); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerReturnsAnswerConfiguration() { + MockWebServer mockWebServer = new MockWebServer(); + try { + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + results[0].getAnswer()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerReturnsAnswerWithFiltering() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_UsesStrictFilters_ToReturnAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnaMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions qnaMakerOptions = new QnAMakerOptions() { + { + setStrictFilters(new Metadata[] { new Metadata() { + { + setName("topic"); + setValue("value"); + } + } }); + setTop(1); + } + }; + QnAMaker qna = new QnAMaker(qnaMakerEndpoint, qnaMakerOptions); + ObjectMapper objectMapper = new ObjectMapper(); + + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), qnaMakerOptions).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + results[0].getAnswer()); + Assert.assertEquals("topic", results[0].getMetadata()[0].getName()); + Assert.assertEquals("value", results[0].getMetadata()[0].getValue()); + + JsonNode obj = null; + try { + RecordedRequest request = mockWebServer.takeRequest(); + obj = objectMapper.readTree(request.getBody().readUtf8()); + } catch (IOException | InterruptedException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + // verify we are actually passing on the options + Assert.assertEquals(1, obj.get("top").asInt()); + Assert.assertEquals("topic", obj.get("strictFilters").get(0).get("name").asText()); + Assert.assertEquals("value", obj.get("strictFilters").get(0).get("value").asText()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerSetScoreThresholdWhenThresholdIsZero() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnaMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions qnaMakerOptions = new QnAMakerOptions() { + { + setScoreThreshold(0.0f); + } + }; + QnAMaker qnaWithZeroValueThreshold = new QnAMaker(qnaMakerEndpoint, qnaMakerOptions); + + QueryResult[] results = qnaWithZeroValueThreshold.getAnswers(getContext("how do I clean the stove?"), new QnAMakerOptions() { + { + setTop(1); + } + }).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTestThreshold() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_TestThreshold.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions qnaMakerOptions = new QnAMakerOptions() { + { + setTop(1); + setScoreThreshold(0.99F); + } + }; + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, qnaMakerOptions); + + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 0); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTestScoreThresholdTooLargeOutOfRange() { + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(hostname); + } + }; + QnAMakerOptions tooLargeThreshold = new QnAMakerOptions() { + { + setTop(1); + setScoreThreshold(1.1f); + } + }; + Assert.assertThrows(IllegalArgumentException.class, () -> new QnAMaker(qnAMakerEndpoint, tooLargeThreshold)); + } + + @Test + public void qnaMakerTestScoreThresholdTooSmallOutOfRange() { + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(hostname); + } + }; + QnAMakerOptions tooSmallThreshold = new QnAMakerOptions() { + { + setTop(1); + setScoreThreshold(-9000.0f); + } + }; + Assert.assertThrows(IllegalArgumentException.class, () -> new QnAMaker(qnAMakerEndpoint, tooSmallThreshold)); + } + + @Test + public void qnaMakerReturnsAnswerWithContext() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswerWithContext.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnARequestContext context = new QnARequestContext() { + { + setPreviousQnAId(5); + setPreviousUserQuery("how do I clean the stove?"); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + setContext(context); + } + }; + + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options); + + QueryResult[] results = qna.getAnswers(getContext("Where can I buy?"), options).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals(55, (int)results[0].getId()); + Assert.assertEquals(1, (double)results[0].getScore(), 0); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerReturnAnswersWithoutContext() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswerWithoutContext.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(3); + } + }; + + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options); + + QueryResult[] results = qna.getAnswers(getContext("Where can I buy?"), options).join(); + Assert.assertNotNull(results); + Assert.assertEquals(2, results.length); + Assert.assertNotEquals(1, results[0].getScore().intValue()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerReturnsHighScoreWhenIdPassed() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswerWithContext.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + setQnAId(55); + } + }; + + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options); + QueryResult[] results = qna.getAnswers(getContext("Where can I buy?"), options).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals(55, (int)results[0].getId()); + Assert.assertEquals(1, (double)results[0].getScore(), 0); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTestTopOutOfRange() { + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(hostname); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(-1); + setScoreThreshold(0.5f); + } + }; + Assert.assertThrows(IllegalArgumentException.class, () -> new QnAMaker(qnAMakerEndpoint, options)); + } + + @Test + public void qnaMakerTestEndpointEmptyKbId() { + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(new String()); + setEndpointKey(endpointKey); + setHost(hostname); + } + }; + Assert.assertThrows(IllegalArgumentException.class, () -> new QnAMaker(qnAMakerEndpoint, null)); + } + + @Test + public void qnaMakerTestEndpointEmptyEndpointKey() { + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(new String()); + setHost(hostname); + } + }; + Assert.assertThrows(IllegalArgumentException.class, () -> new QnAMaker(qnAMakerEndpoint, null)); + } + + @Test + public void qnaMakerTestEndpointEmptyHost() { + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(new String()); + } + }; + Assert.assertThrows(IllegalArgumentException.class, () -> new QnAMaker(qnAMakerEndpoint, null)); + } + + @Test + public void qnaMakerUserAgent() { + MockWebServer mockWebServer = new MockWebServer(); + try { + QnAMaker qna = this.qnaReturnsAnswer(mockWebServer); + + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null).join(); + RecordedRequest request = mockWebServer.takeRequest(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + results[0].getAnswer()); + + // Verify that we added the bot.builder package details. + Assert.assertTrue(request.getHeader("User-Agent").contains("BotBuilder/4.0.0")); + } catch (Exception ex) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerV2LegacyEndpointShouldThrow() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_LegacyEndpointAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getV2LegacyRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String host = String.format("{%s}/v2.0", endpoint); + QnAMakerEndpoint v2LegacyEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(host); + } + }; + + Assert.assertThrows(UnsupportedOperationException.class, + () -> new QnAMaker(v2LegacyEndpoint,null)); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerV3LeagacyEndpointShouldThrow() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_LegacyEndpointAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getV3LegacyRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String host = String.format("{%s}/v3.0", endpoint); + QnAMakerEndpoint v3LegacyEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(host); + } + }; + + Assert.assertThrows(UnsupportedOperationException.class, + () -> new QnAMaker(v3LegacyEndpoint,null)); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerReturnsAnswerWithMetadataBoost() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswersWithMetadataBoost.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options); + + QueryResult[] results = qna.getAnswers(getContext("who loves me?"), options).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("Kiki", results[0].getAnswer()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTestThresholdInQueryOption() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer_GivenScoreThresholdQueryOption.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions queryOptionsWithScoreThreshold = new QnAMakerOptions() { + { + setScoreThreshold(0.5f); + setTop(2); + } + }; + + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, queryOptionsWithScoreThreshold); + + ObjectMapper objectMapper = new ObjectMapper(); + + QueryResult[] results = qna.getAnswers(getContext("What happens when you hug a porcupine?"), queryOptionsWithScoreThreshold).join(); + RecordedRequest request = mockWebServer.takeRequest(); + JsonNode obj = objectMapper.readTree(request.getBody().readUtf8()); + + Assert.assertNotNull(results); + + Assert.assertEquals(2, obj.get("top").asInt()); + Assert.assertEquals(0.5, obj.get("scoreThreshold").asDouble(), 0); + + } catch (Exception ex) { + ex.printStackTrace(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTestUnsuccessfulResponse() { + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse().setResponseCode(502)); + try { + String url = this.getRequestUrl(); + String finalEndpoint = String.format("%s:%s", hostname, mockWebServer.url(url).port()); + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, null); + Assert.assertThrows(CompletionException.class, () -> qna.getAnswers(getContext("how do I clean the stove?"), null)); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerIsTestTrue() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_IsTest_True.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions qnaMakerOptions = new QnAMakerOptions() { + { + setTop(1); + setIsTest(true); + } + }; + + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, qnaMakerOptions); + + QueryResult[] results = qna.getAnswers(getContext("Q11"), qnaMakerOptions).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 0); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerRankerTypeQuestionOnly() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_RankerType_QuestionOnly.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions qnaMakerOptions = new QnAMakerOptions() { + { + setTop(1); + setRankerType("QuestionOnly"); + } + }; + + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, qnaMakerOptions); + + QueryResult[] results = qna.getAnswers(getContext("Q11"), qnaMakerOptions).join(); + Assert.assertNotNull(results); + Assert.assertEquals(2, results.length); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerTestOptionsHydration() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String url = this.getRequestUrl(); + String endpoint = ""; + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + + QnAMakerOptions noFiltersOptions = new QnAMakerOptions() { + { + setTop(30); + } + }; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + Metadata strictFilterMovie = new Metadata() { + { + setName("movie"); + setValue("disney"); + } + }; + Metadata strictFilterHome = new Metadata() { + { + setName("home"); + setValue("floating"); + } + }; + Metadata strictFilterDog = new Metadata() { + { + setName("dog"); + setValue("samoyed"); + } + }; + Metadata[] oneStrictFilters = new Metadata[] {strictFilterMovie}; + Metadata[] twoStrictFilters = new Metadata[] {strictFilterMovie, strictFilterHome}; + Metadata[] allChangedRequestOptionsFilters = new Metadata[] {strictFilterDog}; + QnAMakerOptions oneFilteredOption = new QnAMakerOptions() { + { + setTop(30); + setStrictFilters(oneStrictFilters); + } + }; + QnAMakerOptions twoStrictFiltersOptions = new QnAMakerOptions() { + { + setTop(30); + setStrictFilters(twoStrictFilters); + } + }; + QnAMakerOptions allChangedRequestOptions = new QnAMakerOptions() { + { + setTop(2000); + setScoreThreshold(0.4f); + setStrictFilters(allChangedRequestOptionsFilters); + } + }; + + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, noFiltersOptions); + + TurnContext context = getContext("up"); + + // Ensure that options from previous requests do not bleed over to the next, + // And that the options set in the constructor are not overwritten improperly by options passed into .GetAnswersAsync() + CapturedRequest[] requestContent = new CapturedRequest[6]; + ObjectMapper objectMapper = new ObjectMapper(); + RecordedRequest request; + + qna.getAnswers(context, noFiltersOptions).join(); + request = mockWebServer.takeRequest(); + requestContent[0] = objectMapper.readValue(request.getBody().readUtf8(), CapturedRequest.class); + + this.enqueueResponse(mockWebServer, response); + + qna.getAnswers(context, twoStrictFiltersOptions).join(); + request = mockWebServer.takeRequest(); + requestContent[1] = objectMapper.readValue(request.getBody().readUtf8(), CapturedRequest.class); + + this.enqueueResponse(mockWebServer, response); + + qna.getAnswers(context, oneFilteredOption).join(); + request = mockWebServer.takeRequest(); + requestContent[2] = objectMapper.readValue(request.getBody().readUtf8(), CapturedRequest.class); + + this.enqueueResponse(mockWebServer, response); + + qna.getAnswers(context, null).join(); + request = mockWebServer.takeRequest(); + requestContent[3] = objectMapper.readValue(request.getBody().readUtf8(), CapturedRequest.class); + + this.enqueueResponse(mockWebServer, response); + + qna.getAnswers(context, allChangedRequestOptions).join(); + request = mockWebServer.takeRequest(); + requestContent[4] = objectMapper.readValue(request.getBody().readUtf8(), CapturedRequest.class); + + this.enqueueResponse(mockWebServer, response); + + qna.getAnswers(context, null).join(); + request = mockWebServer.takeRequest(); + requestContent[5] = objectMapper.readValue(request.getBody().readUtf8(), CapturedRequest.class); + + + Assert.assertTrue(requestContent[0].getStrictFilters().length == 0); + Assert.assertEquals(2, requestContent[1].getStrictFilters().length); + Assert.assertTrue(requestContent[2].getStrictFilters().length == 1); + Assert.assertTrue(requestContent[3].getStrictFilters().length == 0); + + Assert.assertEquals(2000, requestContent[4].getTop().intValue()); + Assert.assertEquals(0.42, Math.round(requestContent[4].getScoreThreshold().doubleValue()), 1); + Assert.assertTrue(requestContent[4].getStrictFilters().length == 1); + + Assert.assertEquals(30, requestContent[5].getTop().intValue()); + Assert.assertEquals(0.3, Math.round(requestContent[5].getScoreThreshold().doubleValue()),1); + Assert.assertTrue(requestContent[5].getStrictFilters().length == 0); + + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + LoggerFactory.getLogger(QnAMakerTests.class).error(e.getMessage()); + } + } + } + + @Test + public void qnaMakerStrictFiltersCompoundOperationType() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + Metadata strictFilterMovie = new Metadata() { + { + setName("movie"); + setValue("disney"); + } + }; + Metadata strictFilterProduction = new Metadata() { + { + setName("production"); + setValue("Walden"); + } + }; + Metadata[] strictFilters = new Metadata[] {strictFilterMovie, strictFilterProduction}; + QnAMakerOptions oneFilteredOption = new QnAMakerOptions() { + { + setTop(30); + setStrictFilters(strictFilters); + setStrictFiltersJoinOperator(JoinOperator.OR); + } + }; + + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, oneFilteredOption); + + TurnContext context = getContext("up"); + ObjectMapper objectMapper = new ObjectMapper(); + + QueryResult[] noFilterResults1 = qna.getAnswers(context, oneFilteredOption).join(); + RecordedRequest request = mockWebServer.takeRequest(); + JsonNode requestContent = objectMapper.readTree(request.getBody().readUtf8()); + Assert.assertEquals(2, oneFilteredOption.getStrictFilters().length); + Assert.assertEquals(JoinOperator.OR, oneFilteredOption.getStrictFiltersJoinOperator()); + }catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void telemetryNullTelemetryClient() { + // Arrange + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + + // Act (Null Telemetry client) + // This will default to the NullTelemetryClient which no-ops all calls. + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options, null, true); + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null).join(); + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", results[0].getAnswer()); + Assert.assertEquals("Editorial", results[0].getSource()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void telemetryReturnsAnswer() { + // Arrange + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + + BotTelemetryClient telemetryClient = Mockito.mock(BotTelemetryClient.class); + + // Act - See if we get data back in telemetry + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options, telemetryClient, true); + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null).join(); + // Assert - Check Telemetry logged + // verify BotTelemetryClient was invoked 1 times, and capture arguments. + verify(telemetryClient, times(1)).trackEvent( + eventNameCaptor.capture(), + propertiesCaptor.capture(), + metricsCaptor.capture() + ); + List eventNames = eventNameCaptor.getAllValues(); + List> properties = propertiesCaptor.getAllValues(); + List> metrics = metricsCaptor.getAllValues(); ; + Assert.assertEquals(eventNames.get(0), QnATelemetryConstants.QNA_MSG_EVENT); + Assert.assertTrue(properties.get(0).containsKey("knowledgeBaseId")); + Assert.assertTrue(properties.get(0).containsKey("matchedQuestion")); + Assert.assertTrue(properties.get(0).containsKey("question")); + Assert.assertTrue(properties.get(0).containsKey("questionId")); + Assert.assertTrue(properties.get(0).containsKey("answer")); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", properties.get(0).get("answer")); + Assert.assertTrue(properties.get(0).containsKey("articleFound")); + Assert.assertTrue(metrics.get(0).size() == 1); + + // Assert - Validate we didn't break QnA functionality. + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", results[0].getAnswer()); + Assert.assertEquals("Editorial", results[0].getSource()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void telemetryReturnsAnswerWhenNoAnswerFoundInKB() { + // Arrange + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer_WhenNoAnswerFoundInKb.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + + BotTelemetryClient telemetryClient = Mockito.mock(BotTelemetryClient.class); + + // Act - See if we get data back in telemetry + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options, telemetryClient, true); + QueryResult[] results = qna.getAnswers(getContext("what is the answer to my nonsense question?"), null).join(); + // Assert - Check Telemetry logged + // verify BotTelemetryClient was invoked 1 times, and capture arguments. + verify(telemetryClient, times(1)).trackEvent( + eventNameCaptor.capture(), + propertiesCaptor.capture(), + metricsCaptor.capture() + ); + List eventNames = eventNameCaptor.getAllValues(); + List> properties = propertiesCaptor.getAllValues(); + List> metrics = metricsCaptor.getAllValues(); + + Assert.assertEquals(eventNames.get(0), QnATelemetryConstants.QNA_MSG_EVENT); + Assert.assertTrue(properties.get(0).containsKey("knowledgeBaseId")); + Assert.assertTrue(properties.get(0).containsKey("matchedQuestion")); + Assert.assertEquals("No Qna Question matched", properties.get(0).get("matchedQuestion")); + Assert.assertTrue(properties.get(0).containsKey("question")); + Assert.assertTrue(properties.get(0).containsKey("questionId")); + Assert.assertTrue(properties.get(0).containsKey("answer")); + Assert.assertEquals("No Qna Answer matched", properties.get(0).get("answer")); + Assert.assertTrue(properties.get(0).containsKey("articleFound")); + Assert.assertTrue(metrics.get(0).isEmpty()); + + // Assert - Validate we didn't break QnA functionality. + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 0); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void telemetryPii() { + // Arrange + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + + BotTelemetryClient telemetryClient = Mockito.mock(BotTelemetryClient.class); + + // Act + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options, telemetryClient, false); + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null).join(); + // verify BotTelemetryClient was invoked 1 times, and capture arguments. + verify(telemetryClient, times(1)).trackEvent( + eventNameCaptor.capture(), + propertiesCaptor.capture(), + metricsCaptor.capture() + ); + List eventNames = eventNameCaptor.getAllValues(); + List> properties = propertiesCaptor.getAllValues(); + List> metrics = metricsCaptor.getAllValues(); + + Assert.assertEquals(eventNames.get(0), QnATelemetryConstants.QNA_MSG_EVENT); + Assert.assertTrue(properties.get(0).containsKey("knowledgeBaseId")); + Assert.assertTrue(properties.get(0).containsKey("matchedQuestion")); + Assert.assertFalse(properties.get(0).containsKey("question")); + Assert.assertTrue(properties.get(0).containsKey("questionId")); + Assert.assertTrue(properties.get(0).containsKey("answer")); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + properties.get(0).get("answer")); + Assert.assertTrue(properties.get(0).containsKey("articleFound")); + Assert.assertTrue(metrics.get(0).size() == 1); + Assert.assertTrue(metrics.get(0).containsKey("score")); + + // Assert - Validate we didn't break QnA functionality. + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + results[0].getAnswer()); + Assert.assertEquals("Editorial", results[0].getSource()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void telemetryOverride() { + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + + BotTelemetryClient telemetryClient = Mockito.mock(BotTelemetryClient.class); + + // Act - Override the QnaMaker object to log custom stuff and honor parms passed in. + Map telemetryProperties = new HashMap() {{ + put("Id", "MyID"); + }}; + + QnAMaker qna = new OverrideTelemetry(qnAMakerEndpoint, options, telemetryClient, false); + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null, telemetryProperties, null).join(); + + // verify BotTelemetryClient was invoked 2 times, and capture arguments. + verify(telemetryClient, times(2)).trackEvent( + eventNameCaptor.capture(), + propertiesCaptor.capture() + ); + List eventNames = eventNameCaptor.getAllValues(); + List> properties = propertiesCaptor.getAllValues(); + + Assert.assertEquals(2, eventNames.size()); + Assert.assertEquals(eventNames.get(0), QnATelemetryConstants.QNA_MSG_EVENT); + Assert.assertTrue(properties.get(0).size() == 2); + Assert.assertTrue(properties.get(0).containsKey("MyImportantProperty")); + Assert.assertEquals("myImportantValue", properties.get(0).get("MyImportantProperty")); + Assert.assertTrue(properties.get(0).containsKey("Id")); + Assert.assertEquals("MyID", properties.get(0).get("Id")); + + Assert.assertEquals("MySecondEvent", eventNames.get(1)); + Assert.assertTrue(properties.get(1).containsKey("MyImportantProperty2")); + Assert.assertEquals("myImportantValue2", properties.get(1).get("MyImportantProperty2")); + + // Validate we didn't break QnA functionality. + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + results[0].getAnswer()); + Assert.assertEquals("Editorial", results[0].getSource()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void telemetryAdditionalPropsMetrics() { + //Arrange + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + + BotTelemetryClient telemetryClient = Mockito.mock(BotTelemetryClient.class); + + // Act - Pass in properties during QnA invocation + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options, telemetryClient, false); + Map telemetryProperties = new HashMap() { + { + put("MyImportantProperty", "myImportantValue"); + } + }; + Map telemetryMetrics = new HashMap() { + { + put("MyImportantMetric", 3.14159); + } + }; + + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null, telemetryProperties, telemetryMetrics).join(); + // Assert - added properties were added. + // verify BotTelemetryClient was invoked 1 times, and capture arguments. + verify(telemetryClient, times(1)).trackEvent( + eventNameCaptor.capture(), + propertiesCaptor.capture(), + metricsCaptor.capture() + ); + List eventNames = eventNameCaptor.getAllValues(); + List> properties = propertiesCaptor.getAllValues(); + List> metrics = metricsCaptor.getAllValues(); + + Assert.assertEquals(eventNames.get(0), QnATelemetryConstants.QNA_MSG_EVENT); + Assert.assertTrue(properties.get(0).containsKey(QnATelemetryConstants.KNOWLEDGE_BASE_ID_PROPERTY)); + Assert.assertFalse(properties.get(0).containsKey(QnATelemetryConstants.QUESTION_PROPERTY)); + Assert.assertTrue(properties.get(0).containsKey(QnATelemetryConstants.MATCHED_QUESTION_PROPERTY)); + Assert.assertTrue(properties.get(0).containsKey(QnATelemetryConstants.QUESTION_ID_PROPERTY)); + Assert.assertTrue(properties.get(0).containsKey(QnATelemetryConstants.ANSWER_PROPERTY)); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + properties.get(0).get("answer")); + Assert.assertTrue(properties.get(0).containsKey("MyImportantProperty")); + Assert.assertEquals("myImportantValue", properties.get(0).get("MyImportantProperty")); + + Assert.assertEquals(2, metrics.get(0).size()); + Assert.assertTrue(metrics.get(0).containsKey("score")); + Assert.assertTrue(metrics.get(0).containsKey("MyImportantMetric")); + Assert.assertTrue(Double.compare((double)metrics.get(0).get("MyImportantMetric"), 3.14159) == 0); + + // Validate we didn't break QnA functionality. + Assert.assertNotNull(results); + Assert.assertTrue(results.length == 1); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + results[0].getAnswer()); + Assert.assertEquals("Editorial", results[0].getSource()); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void telemetryAdditionalPropsOverride() { + // Arrange + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + + BotTelemetryClient telemetryClient = Mockito.mock(BotTelemetryClient.class); + + // Act - Pass in properties during QnA invocation that override default properties + // NOTE: We are invoking this with PII turned OFF, and passing a PII property (originalQuestion). + QnAMaker qna = new QnAMaker(qnAMakerEndpoint, options, telemetryClient, false); + Map telemetryProperties = new HashMap() { + { + put("knowledgeBaseId", "myImportantValue"); + put("originalQuestion", "myImportantValue2"); + } + }; + Map telemetryMetrics = new HashMap() { + { + put("score", 3.14159); + } + }; + + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null, telemetryProperties, telemetryMetrics).join(); + // Assert - added properties were added. + // verify BotTelemetryClient was invoked 1 times, and capture arguments. + verify(telemetryClient, times(1)).trackEvent( + eventNameCaptor.capture(), + propertiesCaptor.capture(), + metricsCaptor.capture() + ); + List eventNames = eventNameCaptor.getAllValues(); + List> properties = propertiesCaptor.getAllValues(); + List> metrics = metricsCaptor.getAllValues(); + + Assert.assertEquals(1, eventNames.size()); + Assert.assertEquals(eventNames.get(0), QnATelemetryConstants.QNA_MSG_EVENT); + Assert.assertTrue(properties.get(0).containsKey("knowledgeBaseId")); + Assert.assertEquals("myImportantValue", properties.get(0).get("knowledgeBaseId")); + Assert.assertTrue(properties.get(0).containsKey("matchedQuestion")); + Assert.assertEquals("myImportantValue2", properties.get(0).get("originalQuestion")); + Assert.assertFalse(properties.get(0).containsKey("question")); + Assert.assertTrue(properties.get(0).containsKey("questionId")); + Assert.assertTrue(properties.get(0).containsKey("answer")); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + properties.get(0).get("answer")); + Assert.assertFalse(properties.get(0).containsKey("MyImportantProperty")); + + Assert.assertEquals(1, metrics.get(0).size()); + Assert.assertTrue(metrics.get(0).containsKey("score")); + Assert.assertTrue(Double.compare((double)metrics.get(0).get("score"), 3.14159) == 0); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + @Test + public void telemetryFillPropsOverride() { + //Arrange + MockWebServer mockWebServer = new MockWebServer(); + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer, response, url).port()); + } + String finalEndpoint = endpoint; + QnAMakerEndpoint qnAMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions options = new QnAMakerOptions() { + { + setTop(1); + } + }; + + BotTelemetryClient telemetryClient = Mockito.mock(BotTelemetryClient.class); + + // Act - Pass in properties during QnA invocation that override default properties + // In addition Override with derivation. This presents an interesting question of order of setting properties. + // If I want to override "originalQuestion" property: + // - Set in "Stock" schema + // - Set in derived QnAMaker class + // - Set in GetAnswersAsync + // Logically, the GetAnswersAync should win. But ultimately OnQnaResultsAsync decides since it is the last + // code to touch the properties before logging (since it actually logs the event). + QnAMaker qna = new OverrideFillTelemetry(qnAMakerEndpoint, options, telemetryClient, false); + Map telemetryProperties = new HashMap() { + { + put("knowledgeBaseId", "myImportantValue"); + put("matchedQuestion", "myImportantValue2"); + } + }; + Map telemetryMetrics = new HashMap() { + { + put("score", 3.14159); + } + }; + + QueryResult[] results = qna.getAnswers(getContext("how do I clean the stove?"), null, telemetryProperties, telemetryMetrics).join(); + // Assert - added properties were added. + // verify BotTelemetryClient was invoked 2 times calling different trackEvents methods, and capture arguments. + verify(telemetryClient, times(1)).trackEvent( + eventNameCaptor.capture(), + propertiesCaptor.capture(), + metricsCaptor.capture() + ); + List eventNames = eventNameCaptor.getAllValues(); + List> properties = propertiesCaptor.getAllValues(); + List> metrics = metricsCaptor.getAllValues(); + + Assert.assertEquals(eventNames.get(0), QnATelemetryConstants.QNA_MSG_EVENT); + Assert.assertEquals(6, properties.get(0).size()); + Assert.assertTrue(properties.get(0).containsKey("knowledgeBaseId")); + Assert.assertEquals("myImportantValue", properties.get(0).get("knowledgeBaseId")); + Assert.assertTrue(properties.get(0).containsKey("matchedQuestion")); + Assert.assertEquals("myImportantValue2", properties.get(0).get("matchedQuestion")); + Assert.assertTrue(properties.get(0).containsKey("questionId")); + Assert.assertTrue(properties.get(0).containsKey("answer")); + Assert.assertEquals("BaseCamp: You can use a damp rag to clean around the Power Pack", + properties.get(0).get("answer")); + Assert.assertTrue(properties.get(0).containsKey("articleFound")); + Assert.assertTrue(properties.get(0).containsKey("MyImportantProperty")); + Assert.assertEquals("myImportantValue", properties.get(0).get("MyImportantProperty")); + + Assert.assertEquals(1, metrics.get(0).size()); + Assert.assertTrue(metrics.get(0).containsKey("score")); + Assert.assertTrue(Double.compare((double)metrics.get(0).get("score"), 3.14159) == 0); + } catch (Exception e) { + fail(); + } finally { + try { + mockWebServer.shutdown(); + } catch (IOException e) { + // Empty error + } + } + } + + private static TurnContext getContext(String utterance) { + TestAdapter b = new TestAdapter(); + Activity a = new Activity() { + { + setType(ActivityTypes.MESSAGE); + setText(utterance); + setConversation(new ConversationAccount()); + setRecipient(new ChannelAccount()); + setFrom(new ChannelAccount()); + } + }; + + return new TurnContextImpl(b, a); + } + + private TestFlow createFlow(Dialog rootDialog, String testName) { + Storage storage = new MemoryStorage(); + UserState userState = new UserState(storage); + ConversationState conversationState = new ConversationState(storage); + + TestAdapter adapter = new TestAdapter(TestAdapter.createConversationReference(testName, "User1", "Bot")); + adapter + .useStorage(storage) + .useBotState(userState, conversationState) + .use(new TranscriptLoggerMiddleware(new TraceTranscriptLogger())); + + DialogManager dm = new DialogManager(rootDialog, null); + return new TestFlow(adapter, (turnContext) -> dm.onTurn(turnContext).thenApply(task -> null)); + } + + public class QnAMakerTestDialog extends ComponentDialog implements DialogDependencies { + + public QnAMakerTestDialog(String knowledgeBaseId, String endpointKey, String hostName, OkHttpClient httpClient) { + super("QnaMakerTestDialog"); + addDialog(new QnAMakerDialog(knowledgeBaseId, endpointKey, hostName, null, + null, null, null, null, + null, null, httpClient)); + } + + @Override + public CompletableFuture beginDialog(DialogContext outerDc, Object options) { + return this.continueDialog(outerDc); + } + + @Override + public CompletableFuture continueDialog(DialogContext dc) { + if (dc.getContext().getActivity().getText() == "moo") { + return dc.getContext().sendActivity("Yippee ki-yay!").thenApply(task -> END_OF_TURN); + } + + return dc.beginDialog("qnaDialog").thenApply(task -> task); + } + + public List getDependencies() { + return getDialogs().getDialogs().stream().collect(Collectors.toList()); + } + + @Override + public CompletableFuture resumeDialog(DialogContext dc, DialogReason reason, Object result) { + if(!(boolean)result) { + dc.getContext().sendActivity("I didn't understand that."); + } + + return super.resumeDialog(dc, reason, result).thenApply(task -> task); + } + } + + private QnAMaker qnaReturnsAnswer(MockWebServer mockWebServer) { + try { + String content = readFileContent("QnaMaker_ReturnsAnswer.json"); + ObjectMapper mapper = new ObjectMapper(); + JsonNode response = mapper.readTree(content); + String url = this.getRequestUrl(); + String endpoint = ""; + if (this.mockQnAResponse) { + endpoint = String.format("%s:%s", hostname, initializeMockServer(mockWebServer,response, url).port()); + } + String finalEndpoint = endpoint; + // Mock Qna + QnAMakerEndpoint qnaMakerEndpoint = new QnAMakerEndpoint() { + { + setKnowledgeBaseId(knowledgeBaseId); + setEndpointKey(endpointKey); + setHost(finalEndpoint); + } + }; + QnAMakerOptions qnaMakerOptions = new QnAMakerOptions() { + { + setTop(1); + } + }; + return new QnAMaker(qnaMakerEndpoint, qnaMakerOptions); + } catch (Exception e) { + return null; + } + } + + private String readFileContent (String fileName) throws IOException { + String path = Paths.get("", "src", "test", "java", "com", "microsoft", "bot", "ai", "qna", + "testData", fileName).toAbsolutePath().toString(); + File file = new File(path); + return FileUtils.readFileToString(file, "utf-8"); + } + + private HttpUrl initializeMockServer(MockWebServer mockWebServer, JsonNode response, String url) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + String mockResponse = mapper.writeValueAsString(response); + mockWebServer.enqueue(new MockResponse() + .addHeader("Content-Type", "application/json; charset=utf-8") + .setBody(mockResponse)); + + try { + mockWebServer.start(); + } catch (Exception e) { + // Empty error + } + return mockWebServer.url(url); + } + + private void enqueueResponse(MockWebServer mockWebServer, JsonNode response) throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + String mockResponse = mapper.writeValueAsString(response); + mockWebServer.enqueue(new MockResponse() + .addHeader("Content-Type", "application/json; charset=utf-8") + .setBody(mockResponse)); + } + + public class OverrideTelemetry extends QnAMaker { + + public OverrideTelemetry(QnAMakerEndpoint endpoint, QnAMakerOptions options, + BotTelemetryClient telemetryClient, Boolean logPersonalInformation) { + super(endpoint, options, telemetryClient, logPersonalInformation); + } + + @Override + protected CompletableFuture onQnaResults(QueryResult[] queryResults, TurnContext turnContext, + Map telemetryProperties, + Map telemetryMetrics) { + Map properties = telemetryProperties == null ? new HashMap() : telemetryProperties; + + // GetAnswerAsync overrides derived class. + properties.put("MyImportantProperty", "myImportantValue"); + + // Log event + BotTelemetryClient telemetryClient = getTelemetryClient(); + telemetryClient.trackEvent(QnATelemetryConstants.QNA_MSG_EVENT, properties); + + // Create second event. + Map secondEventProperties = new HashMap(); + secondEventProperties.put("MyImportantProperty2", "myImportantValue2"); + telemetryClient.trackEvent("MySecondEvent", secondEventProperties); + return CompletableFuture.completedFuture(null); + } + + } + + public class OverrideFillTelemetry extends QnAMaker { + + public OverrideFillTelemetry(QnAMakerEndpoint endpoint, QnAMakerOptions options, + BotTelemetryClient telemetryClient, Boolean logPersonalInformation) { + super(endpoint, options, telemetryClient, logPersonalInformation); + } + + @Override + protected CompletableFuture onQnaResults(QueryResult[] queryResults, TurnContext turnContext, + Map telemetryProperties, + Map telemetryMetrics) throws IOException { + return this.fillQnAEvent(queryResults, turnContext, telemetryProperties, telemetryMetrics).thenAccept(eventData -> { + // Add my property + eventData.getLeft().put("MyImportantProperty", "myImportantValue"); + + BotTelemetryClient telemetryClient = this.getTelemetryClient(); + + // Log QnaMessage event + telemetryClient.trackEvent(QnATelemetryConstants.QNA_MSG_EVENT, eventData.getLeft(), eventData.getRight()); + + // Create second event. + Map secondEventProperties = new HashMap(){ + { + put("MyImportantProperty2", "myImportantValue2"); + } + }; + telemetryClient.trackEvent("MySecondEvent", secondEventProperties); + }); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static class CapturedRequest { + private String[] questions; + private Integer top; + private Metadata[] strictFilters; + private Metadata[] MetadataBoost; + private Float scoreThreshold; + + public String[] getQuestions() { + return questions; + } + + public void setQuestions(String[] questions) { + this.questions = questions; + } + + public Integer getTop() { + return top; + } + + public Metadata[] getStrictFilters() { + return strictFilters; + } + + public Float getScoreThreshold() { + return scoreThreshold; + } + } +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerTraceInfoTests.java b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerTraceInfoTests.java new file mode 100644 index 000000000..2e9200c60 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/QnAMakerTraceInfoTests.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.bot.ai.qna; + +import java.io.IOException; +import java.util.UUID; + +import com.microsoft.bot.ai.qna.models.QnAMakerTraceInfo; +import com.microsoft.bot.ai.qna.models.QueryResult; +import com.microsoft.bot.restclient.serializer.JacksonAdapter; + +import org.junit.Assert; +import org.junit.Test; + +public class QnAMakerTraceInfoTests { + + @Test + public void qnaMakerTraceInfoSerialization() throws IOException { + QueryResult[] queryResults = new QueryResult[] { new QueryResult() { + { + setQuestions(new String[] { "What's your name?" }); + setAnswer("My name is Mike"); + setScore(0.9f); + } + } }; + + QnAMakerTraceInfo qnaMakerTraceInfo = new QnAMakerTraceInfo() { + { + setQueryResults(queryResults); + setKnowledgeBaseId(UUID.randomUUID().toString()); + setScoreThreshold(0.5f); + setTop(1); + } + }; + + JacksonAdapter jacksonAdapter = new JacksonAdapter(); + String serialized = jacksonAdapter.serialize(qnaMakerTraceInfo); + QnAMakerTraceInfo deserialized = jacksonAdapter.deserialize(serialized, QnAMakerTraceInfo.class); + + Assert.assertNotNull(deserialized); + Assert.assertNotNull(deserialized.getQueryResults()); + Assert.assertNotNull(deserialized.getKnowledgeBaseId()); + Assert.assertEquals(0.5, deserialized.getScoreThreshold(), 0); + Assert.assertEquals(1, deserialized.getTop(), 0); + Assert.assertEquals(qnaMakerTraceInfo.getQueryResults()[0].getQuestions()[0], + deserialized.getQueryResults()[0].getQuestions()[0]); + Assert.assertEquals(qnaMakerTraceInfo.getQueryResults()[0].getAnswer(), + deserialized.getQueryResults()[0].getAnswer()); + Assert.assertEquals(qnaMakerTraceInfo.getKnowledgeBaseId(), deserialized.getKnowledgeBaseId()); + Assert.assertEquals(qnaMakerTraceInfo.getScoreThreshold(), deserialized.getScoreThreshold()); + Assert.assertEquals(qnaMakerTraceInfo.getTop(), deserialized.getTop()); + } +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_IsTest_True.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_IsTest_True.json new file mode 100644 index 000000000..263f6a1e5 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_IsTest_True.json @@ -0,0 +1,13 @@ +{ + "activeLearningEnabled": true, + "answers": [ + { + "questions": [], + "answer": "No good match found in KB.", + "score": 0, + "id": -1, + "source": null, + "metadata": [] + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_LegacyEndpointAnswer.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_LegacyEndpointAnswer.json new file mode 100644 index 000000000..158877934 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_LegacyEndpointAnswer.json @@ -0,0 +1,13 @@ +{ + "answers": [ + { + "score": 30.500827898, + "qnaId": 18, + "answer": "To be the very best, you gotta catch 'em all", + "source": "Custom Editorial", + "questions": [ + "How do I be the best?" + ] + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_RankerType_QuestionOnly.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_RankerType_QuestionOnly.json new file mode 100644 index 000000000..ff2b0f788 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_RankerType_QuestionOnly.json @@ -0,0 +1,35 @@ +{ + "activeLearningEnabled": false, + "answers": [ + { + "questions": [ + "Q1" + ], + "answer": "A1", + "score": 80, + "id": 15, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q2" + ], + "answer": "A2", + "score": 78, + "id": 16, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnAnswer_MultiTurnLevel1.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnAnswer_MultiTurnLevel1.json new file mode 100644 index 000000000..48f461826 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnAnswer_MultiTurnLevel1.json @@ -0,0 +1,19 @@ +{ + "answers": [ + { + "questions": [ + "I accidentally deleted a part of my QnA Maker, what should I do?" + ], + "answer": "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.", + "score": 89.99, + "id": 1, + "source": "https://docs.microsoft.com/en-us/azure/cognitive-services/qnamaker/troubleshooting", + "metadata": [], + "context": { + "isContextOnly": false, + "prompts": [] + } + } + ], + "activeLearningEnabled": false +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnAnswer_withPrompts.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnAnswer_withPrompts.json new file mode 100644 index 000000000..67f5ce464 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnAnswer_withPrompts.json @@ -0,0 +1,38 @@ +{ + "activeLearningEnabled": true, + "answers": [ + { + "questions": [ + "Issues related to KB" + ], + "answer": "Please select one of the following KB issues. ", + "score": 98.0, + "id": 27, + "source": "Editorial", + "metadata": [], + "context": { + "isContextOnly": false, + "prompts": [ + { + "displayOrder": 0, + "qnaId": 1, + "qna": null, + "displayText": "Accidently deleted KB" + }, + { + "displayOrder": 0, + "qnaId": 3, + "qna": null, + "displayText": "KB Size Limits" + }, + { + "displayOrder": 0, + "qnaId": 29, + "qna": null, + "displayText": "Other Issues" + } + ] + } + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswer.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswer.json new file mode 100644 index 000000000..a5fdc06b5 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswer.json @@ -0,0 +1,26 @@ +{ + "activeLearningEnabled": true, + "answers": [ + { + "questions": [ + "how do I clean the stove?" + ], + "answer": "BaseCamp: You can use a damp rag to clean around the Power Pack", + "score": 100, + "id": 5, + "source": "Editorial", + "metadata": [], + "context": { + "isContextOnly": true, + "prompts": [ + { + "displayOrder": 0, + "qnaId": 55, + "qna": null, + "displayText": "Where can I buy?" + } + ] + } + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswerWithContext.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswerWithContext.json new file mode 100644 index 000000000..a1c6989ae --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswerWithContext.json @@ -0,0 +1,18 @@ +{ + "answers": [ + { + "questions": [ + "Where can I buy cleaning products?" + ], + "answer": "Any DIY store", + "score": 100, + "id": 55, + "source": "Editorial", + "metadata": [], + "context": { + "isContextOnly": true, + "prompts": [] + } + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswerWithIntent.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswerWithIntent.json new file mode 100644 index 000000000..53d56dc04 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswerWithIntent.json @@ -0,0 +1,26 @@ +{ + "activeLearningEnabled": true, + "answers": [ + { + "questions": [ + "how do I clean the stove?" + ], + "answer": "intent=DeferToRecognizer_xxx", + "score": 100, + "id": 5, + "source": "Editorial", + "metadata": [], + "context": { + "isContextOnly": true, + "prompts": [ + { + "displayOrder": 0, + "qnaId": 55, + "qna": null, + "displayText": "Where can I buy?" + } + ] + } + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswerWithoutContext.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswerWithoutContext.json new file mode 100644 index 000000000..b5cf68875 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswerWithoutContext.json @@ -0,0 +1,32 @@ +{ + "answers": [ + { + "questions": [ + "Where can I buy home appliances?" + ], + "answer": "Any Walmart store", + "score": 68, + "id": 56, + "source": "Editorial", + "metadata": [], + "context": { + "isContextOnly": false, + "prompts": [] + } + }, + { + "questions": [ + "Where can I buy cleaning products?" + ], + "answer": "Any DIY store", + "score": 56, + "id": 55, + "source": "Editorial", + "metadata": [], + "context": { + "isContextOnly": false, + "prompts": [] + } + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswer_GivenScoreThresholdQueryOption.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswer_GivenScoreThresholdQueryOption.json new file mode 100644 index 000000000..2d9c67652 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswer_GivenScoreThresholdQueryOption.json @@ -0,0 +1,19 @@ +{ + "answers": [ + { + "score": 68.54820341616869, + "Id": 22, + "answer": "Why do you ask?", + "source": "Custom Editorial", + "questions": [ + "what happens when you hug a procupine?" + ], + "metadata": [ + { + "name": "animal", + "value": "procupine" + } + ] + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswer_WhenNoAnswerFoundInKb.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswer_WhenNoAnswerFoundInKb.json new file mode 100644 index 000000000..05e1d3907 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswer_WhenNoAnswerFoundInKb.json @@ -0,0 +1,13 @@ +{ + "answers": [ + { + "questions": [], + "answer": "No good match found in KB.", + "score": 0, + "id": -1, + "source": null, + "metadata": [] + } + ], + "debugInfo": null +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswersWithMetadataBoost.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswersWithMetadataBoost.json new file mode 100644 index 000000000..280467e7b --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsAnswersWithMetadataBoost.json @@ -0,0 +1,19 @@ +{ + "answers": [ + { + "questions": [ + "Who loves me?" + ], + "answer": "Kiki", + "score": 100, + "id": 29, + "source": "Editorial", + "metadata": [ + { + "name": "artist", + "value": "drake" + } + ] + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsNoAnswer.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsNoAnswer.json new file mode 100644 index 000000000..9888a8555 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_ReturnsNoAnswer.json @@ -0,0 +1,4 @@ +{ + "answers": [ + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_TestThreshold.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_TestThreshold.json new file mode 100644 index 000000000..c8973d9e8 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_TestThreshold.json @@ -0,0 +1,3 @@ +{ + "answers": [ ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_TopNAnswer.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_TopNAnswer.json new file mode 100644 index 000000000..17f598471 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_TopNAnswer.json @@ -0,0 +1,65 @@ +{ + "activeLearningEnabled": true, + "answers": [ + { + "questions": [ + "Q1" + ], + "answer": "A1", + "score": 80, + "id": 15, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q2" + ], + "answer": "A2", + "score": 78, + "id": 16, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q3" + ], + "answer": "A3", + "score": 75, + "id": 17, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q4" + ], + "answer": "A4", + "score": 50, + "id": 18, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_TopNAnswer_DisableActiveLearning.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_TopNAnswer_DisableActiveLearning.json new file mode 100644 index 000000000..e3f714416 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_TopNAnswer_DisableActiveLearning.json @@ -0,0 +1,65 @@ +{ + "activeLearningEnabled": false, + "answers": [ + { + "questions": [ + "Q1" + ], + "answer": "A1", + "score": 80, + "id": 15, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q2" + ], + "answer": "A2", + "score": 78, + "id": 16, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q3" + ], + "answer": "A3", + "score": 75, + "id": 17, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + }, + { + "questions": [ + "Q4" + ], + "answer": "A4", + "score": 50, + "id": 18, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_UsesStrictFilters_ToReturnAnswer.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_UsesStrictFilters_ToReturnAnswer.json new file mode 100644 index 000000000..2dbb1d051 --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/QnaMaker_UsesStrictFilters_ToReturnAnswer.json @@ -0,0 +1,19 @@ +{ + "answers": [ + { + "questions": [ + "how do I clean the stove?" + ], + "answer": "BaseCamp: You can use a damp rag to clean around the Power Pack", + "score": 100, + "id": 5, + "source": "Editorial", + "metadata": [ + { + "name": "topic", + "value": "value" + } + ] + } + ] +} diff --git a/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/qnamaker.settings.development.westus.json b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/qnamaker.settings.development.westus.json new file mode 100644 index 000000000..b1071df7e --- /dev/null +++ b/libraries/bot-ai-qna/src/test/java/com/microsoft/bot/ai/qna/testData/qnamaker.settings.development.westus.json @@ -0,0 +1,6 @@ +{ + "qna": { + "sandwichQnA_en_us_qna": "", + "hostname": "" + } +} diff --git a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java index 84a46a6f4..2226517c6 100644 --- a/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java +++ b/libraries/bot-builder/src/test/java/com/microsoft/bot/builder/adapters/TestAdapter.java @@ -255,7 +255,7 @@ public CompletableFuture sendActivities(TurnContext context, System.out.println(String.format("TestAdapter:SendActivities, Count:%s (tid:%s)", activities.size(), Thread.currentThread().getId())); for (Activity act : activities) { - System.out.printf(" :--------\n : To:%s\n", act.getRecipient().getName()); + System.out.printf(" :--------\n : To:%s\n", (act.getRecipient() == null) ? "No recipient set" : act.getRecipient().getName()); System.out.printf(" : From:%s\n", (act.getFrom() == null) ? "No from set" : act.getFrom().getName()); System.out.printf(" : Text:%s\n :---------\n", (act.getText() == null) ? "No text set" : act.getText()); } diff --git a/pom.xml b/pom.xml index 906d834ce..c2a9d09df 100644 --- a/pom.xml +++ b/pom.xml @@ -351,6 +351,11 @@ bot-applicationinsights ${project.version} + + com.microsoft.bot + bot-ai-qna + ${project.version} +