diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/__init__.py index 018d40c95..608ffeef1 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/__init__.py @@ -8,6 +8,7 @@ from .feedback_record import FeedbackRecord from .feedback_records import FeedbackRecords from .generate_answer_request_body import GenerateAnswerRequestBody +from .join_operator import JoinOperator from .metadata import Metadata from .prompt import Prompt from .qnamaker_trace_info import QnAMakerTraceInfo @@ -21,6 +22,7 @@ "FeedbackRecord", "FeedbackRecords", "GenerateAnswerRequestBody", + "JoinOperator", "Metadata", "Prompt", "QnAMakerTraceInfo", diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py index 4a4e9fdd7..dd4104185 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/generate_answer_request_body.py @@ -16,6 +16,10 @@ class GenerateAnswerRequestBody(Model): "qna_id": {"key": "qnaId", "type": "int"}, "is_test": {"key": "isTest", "type": "bool"}, "ranker_type": {"key": "rankerType", "type": "RankerTypes"}, + "strict_filters_join_operator": { + "key": "strictFiltersCompoundOperationType", + "type": "str", + }, } def __init__(self, **kwargs): @@ -28,3 +32,6 @@ def __init__(self, **kwargs): self.qna_id = kwargs.get("qna_id", None) self.is_test = kwargs.get("is_test", None) self.ranker_type = kwargs.get("ranker_type", None) + self.strict_filters_join_operator = kwargs.get( + "strict_filters_join_operator", None + ) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/models/join_operator.py b/libraries/botbuilder-ai/botbuilder/ai/qna/models/join_operator.py new file mode 100644 index 000000000..a454afa81 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/models/join_operator.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum + + +class JoinOperator(str, Enum): + """ + Join Operator for Strict Filters. + + remarks: + -------- + For example, when using multiple filters in a query, if you want results that + have metadata that matches all filters, then use `AND` operator. + + If instead you only wish that the results from knowledge base match + at least one of the filters, then use `OR` operator. + """ + + AND = "AND" + OR = "OR" diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py index 95ae70b81..af4a4ad1c 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py @@ -3,9 +3,18 @@ from .models import Metadata, QnARequestContext from .models.ranker_types import RankerTypes +from .models.join_operator import JoinOperator class QnAMakerOptions: + """ + Defines options used to configure a `QnAMaker` instance. + + remarks: + -------- + All parameters are optional. + """ + def __init__( self, score_threshold: float = 0.0, @@ -16,7 +25,34 @@ def __init__( qna_id: int = None, is_test: bool = False, ranker_type: str = RankerTypes.DEFAULT, + strict_filters_join_operator: str = JoinOperator.AND, ): + """ + Parameters: + ----------- + score_threshold (float): + The minimum score threshold, used to filter returned results. + Values range from score of 0.0 to 1.0. + timeout (int): + The time in milliseconds to wait before the request times out. + top (int): + The number of ranked results to return. + strict_filters ([Metadata]): + Filters to use on queries to a QnA knowledge base, based on a + QnA pair's metadata. + context ([QnARequestContext]): + The context of the previous turn. + qna_id (int): + Id of the current question asked (if available). + is_test (bool): + A value indicating whether to call test or prod environment of a knowledge base. + ranker_type (str): + The QnA ranker type to use. + strict_filters_join_operator (str): + A value indicating how strictly you want to apply strict_filters on QnA pairs' metadata. + For example, when combining several metadata filters, you can determine if you are + concerned with all filters matching or just at least one filter matching. + """ self.score_threshold = score_threshold self.timeout = timeout self.top = top @@ -25,3 +61,4 @@ def __init__( self.qna_id = qna_id self.is_test = is_test self.ranker_type = ranker_type + self.strict_filters_join_operator = strict_filters_join_operator diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py index b12c492c7..1f335f9e6 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/generate_answer_utils.py @@ -144,6 +144,9 @@ def _hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: hydrated_options.qna_id = query_options.qna_id hydrated_options.is_test = query_options.is_test hydrated_options.ranker_type = query_options.ranker_type + hydrated_options.strict_filters_join_operator = ( + query_options.strict_filters_join_operator + ) return hydrated_options @@ -161,6 +164,7 @@ async def _query_qna_service( qna_id=options.qna_id, is_test=options.is_test, ranker_type=options.ranker_type, + strict_filters_join_operator=options.strict_filters_join_operator, ) http_request_helper = HttpRequestUtils(self._http_client) diff --git a/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_And_Operator.json b/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_And_Operator.json new file mode 100644 index 000000000..1bb54754a --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_And_Operator.json @@ -0,0 +1,29 @@ +{ + "answers": [ + { + "questions": [ + "Where can you find Misty", + "Misty" + ], + "answer": "Wherever people are having a swimming good time", + "score": 74.51, + "id": 27, + "source": "Editorial", + "metadata": [ + { + "name": "species", + "value": "human" + }, + { + "name": "type", + "value": "water" + } + ], + "context": { + "isContextOnly": false, + "prompts": [] + } + } + ], + "activeLearningEnabled": true +} diff --git a/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_Or_Operator.json b/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_Or_Operator.json new file mode 100644 index 000000000..3346464fc --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/RetrunsAnswer_WithStrictFilter_Or_Operator.json @@ -0,0 +1,76 @@ +{ + "answers": [ + { + "questions": [ + "Where can you find Squirtle" + ], + "answer": "Did you not see him in the first three balls?", + "score": 80.22, + "id": 28, + "source": "Editorial", + "metadata": [ + { + "name": "species", + "value": "turtle" + }, + { + "name": "type", + "value": "water" + } + ], + "context": { + "isContextOnly": false, + "prompts": [] + } + }, + { + "questions": [ + "Where can you find Ash", + "Ash" + ], + "answer": "I don't know. Maybe ask your little electric mouse friend?", + "score": 63.74, + "id": 26, + "source": "Editorial", + "metadata": [ + { + "name": "species", + "value": "human" + }, + { + "name": "type", + "value": "miscellaneous" + } + ], + "context": { + "isContextOnly": false, + "prompts": [] + } + }, + { + "questions": [ + "Where can you find Misty", + "Misty" + ], + "answer": "Wherever people are having a swimming good time", + "score": 31.13, + "id": 27, + "source": "Editorial", + "metadata": [ + { + "name": "species", + "value": "human" + }, + { + "name": "type", + "value": "water" + } + ], + "context": { + "isContextOnly": false, + "prompts": [] + } + } + ], + "activeLearningEnabled": true +} diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index e733b6564..236594ac0 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -17,6 +17,7 @@ from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions from botbuilder.ai.qna.models import ( FeedbackRecord, + JoinOperator, Metadata, QueryResult, QnARequestContext, @@ -167,6 +168,96 @@ async def test_active_learning_enabled_status(self): self.assertEqual(1, len(result.answers)) self.assertFalse(result.active_learning_enabled) + async def test_returns_answer_with_strict_filters_with_or_operator(self): + # Arrange + question: str = "Where can you find" + response_path: str = "RetrunsAnswer_WithStrictFilter_Or_Operator.json" + response_json = QnaApplicationTest._get_json_for_file(response_path) + + strict_filters = [ + Metadata(name="species", value="human"), + Metadata(name="type", value="water"), + ] + options = QnAMakerOptions( + top=5, + strict_filters=strict_filters, + strict_filters_join_operator=JoinOperator.OR, + ) + qna = QnAMaker(endpoint=QnaApplicationTest.tests_endpoint) + context = QnaApplicationTest._get_context(question, TestAdapter()) + + # Act + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ) as mock_http_client: + result = await qna.get_answers_raw(context, options) + + serialized_http_req_args = mock_http_client.call_args[1]["data"] + req_args = json.loads(serialized_http_req_args) + + # Assert + self.assertIsNotNone(result) + self.assertEqual(3, len(result.answers)) + self.assertEqual( + JoinOperator.OR, req_args["strictFiltersCompoundOperationType"] + ) + + req_args_strict_filters = req_args["strictFilters"] + + first_filter = strict_filters[0] + self.assertEqual(first_filter.name, req_args_strict_filters[0]["name"]) + self.assertEqual(first_filter.value, req_args_strict_filters[0]["value"]) + + second_filter = strict_filters[1] + self.assertEqual(second_filter.name, req_args_strict_filters[1]["name"]) + self.assertEqual(second_filter.value, req_args_strict_filters[1]["value"]) + + async def test_returns_answer_with_strict_filters_with_and_operator(self): + # Arrange + question: str = "Where can you find" + response_path: str = "RetrunsAnswer_WithStrictFilter_And_Operator.json" + response_json = QnaApplicationTest._get_json_for_file(response_path) + + strict_filters = [ + Metadata(name="species", value="human"), + Metadata(name="type", value="water"), + ] + options = QnAMakerOptions( + top=5, + strict_filters=strict_filters, + strict_filters_join_operator=JoinOperator.AND, + ) + qna = QnAMaker(endpoint=QnaApplicationTest.tests_endpoint) + context = QnaApplicationTest._get_context(question, TestAdapter()) + + # Act + with patch( + "aiohttp.ClientSession.post", + return_value=aiounittest.futurized(response_json), + ) as mock_http_client: + result = await qna.get_answers_raw(context, options) + + serialized_http_req_args = mock_http_client.call_args[1]["data"] + req_args = json.loads(serialized_http_req_args) + + # Assert + self.assertIsNotNone(result) + self.assertEqual(1, len(result.answers)) + self.assertEqual( + JoinOperator.AND, req_args["strictFiltersCompoundOperationType"] + ) + + req_args_strict_filters = req_args["strictFilters"] + + first_filter = strict_filters[0] + self.assertEqual(first_filter.name, req_args_strict_filters[0]["name"]) + self.assertEqual(first_filter.value, req_args_strict_filters[0]["value"]) + + second_filter = strict_filters[1] + self.assertEqual(second_filter.name, req_args_strict_filters[1]["name"]) + self.assertEqual(second_filter.value, req_args_strict_filters[1]["value"]) + async def test_returns_answer_using_requests_module(self): question: str = "how do I clean the stove?" response_path: str = "ReturnsAnswer.json"