Skip to content
This repository was archived by the owner on Jan 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions libraries/botbuilder-ai/botbuilder/ai/qna/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +22,7 @@
"FeedbackRecord",
"FeedbackRecords",
"GenerateAnswerRequestBody",
"JoinOperator",
"Metadata",
"Prompt",
"QnAMakerTraceInfo",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
)
21 changes: 21 additions & 0 deletions libraries/botbuilder-ai/botbuilder/ai/qna/models/join_operator.py
Original file line number Diff line number Diff line change
@@ -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"
37 changes: 37 additions & 0 deletions libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
91 changes: 91 additions & 0 deletions libraries/botbuilder-ai/tests/qna/test_qna.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions
from botbuilder.ai.qna.models import (
FeedbackRecord,
JoinOperator,
Metadata,
QueryResult,
QnARequestContext,
Expand Down Expand Up @@ -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"
Expand Down