Repository: microsoft/botbuilder-python

Issue:
"no match response" button present in qna maker dialog when active learning is disabled
Python tracking issue for repo code-owners

See original issue for details: microsoft/botframework-sdk#6146


Discussion:

Resolving patch:
diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py
--- a/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py
+++ b/libraries/botbuilder-ai/botbuilder/ai/qna/utils/qna_card_builder.py
@@ -67,13 +67,6 @@ def get_qna_prompts_card(result: QueryResult, card_no_match_text: str) -> Activi
             for prompt in result.context.prompts
         ]
 
-        # Add No match text
-        button_list.append(
-            CardAction(
-                value=card_no_match_text, type="imBack", title=card_no_match_text,
-            )
-        )
-
         attachment = CardFactory.hero_card(HeroCard(buttons=button_list))
 
         return Activity(


Test patch:
diff --git a/libraries/botbuilder-ai/tests/qna/test_data/QnAMakerDialog_ActiveLearning.json b/libraries/botbuilder-ai/tests/qna/test_data/QnAMakerDialog_ActiveLearning.json
new file mode 100644
--- /dev/null
+++ b/libraries/botbuilder-ai/tests/qna/test_data/QnAMakerDialog_ActiveLearning.json
@@ -0,0 +1,65 @@
+{
+    "answers": [
+        {
+            "questions": [
+                "Esper seeks"
+            ],
+            "answer": "Esper seeks. She's a curious little explorer. Young toddlers seek out new adventures, expanding their knowledge base. It's their job to test limits, to learn about them. It's the adult's job to enforce the limits, while also allowing room for exploration",
+            "score": 79.65,
+            "id": 35,
+            "source": "Editorial",
+            "isDocumentText": false,
+            "metadata": [],
+            "context": {
+                "isContextOnly": false,
+                "prompts": []
+            }
+        },
+        {
+            "questions": [
+                "Esper sups"
+            ],
+            "answer": "Esper sups. She eats just about anything. She loves her broccoli. Anything that she sees her parents eating, she wants to part take in herself.\n\nCaution though. If she spots you eating dessert, you best be prepared to share with her. Best to wait until she goes down for bed and then sneak your favorite snack in, without her prying eyes.",
+            "score": 79.65,
+            "id": 36,
+            "source": "Editorial",
+            "isDocumentText": false,
+            "metadata": [],
+            "context": {
+                "isContextOnly": false,
+                "prompts": []
+            }
+        },
+        {
+            "questions": [
+                "Esper screams"
+            ],
+            "answer": "Esper screams. The currently 1-year old toddler has a brain that's rapidly developing, expanding to new abilities at an alarming rate. With it may come fright or possibly frustration as they understand what could be done, however they need to master how to do a task themselves",
+            "score": 66.89,
+            "id": 34,
+            "source": "Editorial",
+            "isDocumentText": false,
+            "metadata": [],
+            "context": {
+                "isContextOnly": false,
+                "prompts": []
+            }
+        },
+        {
+            "questions": [
+                "Esper sleeps"
+            ],
+            "answer": "Esper sleeps. Esper sleeps on her floor bed. She never had a crib, as her parents placed her directly on the floor bed since birth. With this comes the benefit of not having to have an awkward transition period from crib to bed, when she gets old enough.\n\nThe idea of using the bed is that it offers the child more freedom to move about--more autonomy. Downside is, they will definitely wander off the bed, when they don't want to sleep",
+            "score": 65.71,
+            "id": 33,
+            "source": "Editorial",
+            "isDocumentText": false,
+            "metadata": [],
+            "context": {
+                "isContextOnly": false,
+                "prompts": []
+            }
+        }
+    ],
+    "activeLearningEnabled": true
+}
\ No newline at end of file
diff --git a/libraries/botbuilder-ai/tests/qna/test_data/QnAMakerDialog_MultiTurn_Answer1.json b/libraries/botbuilder-ai/tests/qna/test_data/QnAMakerDialog_MultiTurn_Answer1.json
new file mode 100644
--- /dev/null
+++ b/libraries/botbuilder-ai/tests/qna/test_data/QnAMakerDialog_MultiTurn_Answer1.json
@@ -0,0 +1,32 @@
+{
+    "answers": [
+        {
+            "questions": [
+                "Tell me about birds",
+                "What do you know about birds"
+            ],
+            "answer": "Choose one of the following birds to get more info",
+            "score": 100.0,
+            "id": 37,
+            "source": "Editorial",
+            "isDocumentText": false,
+            "metadata": [],
+            "context": {
+                "isContextOnly": false,
+                "prompts": [
+                    {
+                        "displayOrder": 1,
+                        "qnaId": 38,
+                        "displayText": "Bald Eagle"
+                    },
+                    {
+                        "displayOrder": 2,
+                        "qnaId": 39,
+                        "displayText": "Hummingbird"
+                    }
+                ]
+            }
+        }
+    ],
+    "activeLearningEnabled": true
+}
\ No newline at end of file
diff --git a/libraries/botbuilder-ai/tests/qna/test_data/QnAMakerDialog_MultiTurn_Answer2.json b/libraries/botbuilder-ai/tests/qna/test_data/QnAMakerDialog_MultiTurn_Answer2.json
new file mode 100644
--- /dev/null
+++ b/libraries/botbuilder-ai/tests/qna/test_data/QnAMakerDialog_MultiTurn_Answer2.json
@@ -0,0 +1,20 @@
+{
+    "answers": [
+        {
+            "questions": [
+                "Bald Eagle"
+            ],
+            "answer": "Apparently these guys aren't actually bald!",
+            "score": 100.0,
+            "id": 38,
+            "source": "Editorial",
+            "isDocumentText": false,
+            "metadata": [],
+            "context": {
+                "isContextOnly": true,
+                "prompts": []
+            }
+        }
+    ],
+    "activeLearningEnabled": true
+}
\ No newline at end of file
diff --git a/libraries/botbuilder-ai/tests/qna/test_qna_dialog.py b/libraries/botbuilder-ai/tests/qna/test_qna_dialog.py
new file mode 100644
--- /dev/null
+++ b/libraries/botbuilder-ai/tests/qna/test_qna_dialog.py
@@ -0,0 +1,165 @@
+import json
+from os import path
+from unittest.mock import patch
+import aiounittest
+
+# from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions
+from botbuilder.ai.qna.dialogs import QnAMakerDialog
+from botbuilder.schema import Activity, ActivityTypes
+from botbuilder.core import ConversationState, MemoryStorage, TurnContext
+from botbuilder.core.adapters import TestAdapter, TestFlow
+from botbuilder.dialogs import DialogSet, DialogTurnStatus
+
+
+class QnaMakerDialogTest(aiounittest.AsyncTestCase):
+    # Note this is NOT a real QnA Maker application ID nor a real QnA Maker subscription-key
+    # theses are GUIDs edited to look right to the parsing and validation code.
+
+    _knowledge_base_id: str = "f028d9k3-7g9z-11d3-d300-2b8x98227q8w"
+    _endpoint_key: str = "1k997n7w-207z-36p3-j2u1-09tas20ci6011"
+    _host: str = "https://dummyqnahost.azurewebsites.net/qnamaker"
+
+    _tell_me_about_birds: str = "Tell me about birds"
+    _choose_bird: str = "Choose one of the following birds to get more info"
+    _bald_eagle: str = "Bald Eagle"
+    _esper: str = "Esper"
+
+    DEFAULT_ACTIVE_LEARNING_TITLE: str = "Did you mean:"
+    DEFAULT_NO_MATCH_TEXT: str = "None of the above."
+    DEFAULT_CARD_NO_MATCH_RESPONSE: str = "Thanks for the feedback."
+
+    async def test_multiturn_dialog(self):
+        # Set Up QnAMakerDialog
+        convo_state = ConversationState(MemoryStorage())
+        dialog_state = convo_state.create_property("dialogState")
+        dialogs = DialogSet(dialog_state)
+
+        qna_dialog = QnAMakerDialog(
+            self._knowledge_base_id, self._endpoint_key, self._host
+        )
+        dialogs.add(qna_dialog)
+
+        # Callback that runs the dialog
+        async def execute_qna_dialog(turn_context: TurnContext) -> None:
+            if turn_context.activity.type != ActivityTypes.message:
+                raise TypeError(
+                    "Failed to execute QnA dialog. Should have received a message activity."
+                )
+
+            response_json = self._get_json_res(turn_context.activity.text)
+            dialog_context = await dialogs.create_context(turn_context)
+            with patch(
+                "aiohttp.ClientSession.post",
+                return_value=aiounittest.futurized(response_json),
+            ):
+                results = await dialog_context.continue_dialog()
+
+                if results.status == DialogTurnStatus.Empty:
+                    await dialog_context.begin_dialog("QnAMakerDialog")
+
+                await convo_state.save_changes(turn_context)
+
+        # Send and receive messages from QnA dialog
+        test_adapter = TestAdapter(execute_qna_dialog)
+        test_flow = TestFlow(None, test_adapter)
+        tf2 = await test_flow.send(self._tell_me_about_birds)
+        dialog_reply: Activity = tf2.adapter.activity_buffer[0]
+        self._assert_has_valid_hero_card_buttons(dialog_reply, button_count=2)
+        tf3 = await tf2.assert_reply(self._choose_bird)
+        tf4 = await tf3.send(self._bald_eagle)
+        await tf4.assert_reply("Apparently these guys aren't actually bald!")
+
+    async def test_active_learning(self):
+        # Set Up QnAMakerDialog
+        convo_state = ConversationState(MemoryStorage())
+        dialog_state = convo_state.create_property("dialogState")
+        dialogs = DialogSet(dialog_state)
+
+        qna_dialog = QnAMakerDialog(
+            self._knowledge_base_id, self._endpoint_key, self._host
+        )
+        dialogs.add(qna_dialog)
+
+        # Callback that runs the dialog
+        async def execute_qna_dialog(turn_context: TurnContext) -> None:
+            if turn_context.activity.type != ActivityTypes.message:
+                raise TypeError(
+                    "Failed to execute QnA dialog. Should have received a message activity."
+                )
+
+            response_json = self._get_json_res(turn_context.activity.text)
+            dialog_context = await dialogs.create_context(turn_context)
+            with patch(
+                "aiohttp.ClientSession.post",
+                return_value=aiounittest.futurized(response_json),
+            ):
+                results = await dialog_context.continue_dialog()
+
+                if results.status == DialogTurnStatus.Empty:
+                    await dialog_context.begin_dialog("QnAMakerDialog")
+
+                await convo_state.save_changes(turn_context)
+
+        # Send and receive messages from QnA dialog
+        test_adapter = TestAdapter(execute_qna_dialog)
+        test_flow = TestFlow(None, test_adapter)
+        tf2 = await test_flow.send(self._esper)
+        dialog_reply: Activity = tf2.adapter.activity_buffer[0]
+        self._assert_has_valid_hero_card_buttons(dialog_reply, button_count=3)
+        tf3 = await tf2.assert_reply(self.DEFAULT_ACTIVE_LEARNING_TITLE)
+        tf4 = await tf3.send(self.DEFAULT_NO_MATCH_TEXT)
+        await tf4.assert_reply(self.DEFAULT_CARD_NO_MATCH_RESPONSE)
+
+        print(tf2)
+
+    def _assert_has_valid_hero_card_buttons(
+        self, activity: Activity, button_count: int
+    ):
+        self.assertIsInstance(activity, Activity)
+        attachments = activity.attachments
+        self.assertTrue(attachments)
+        self.assertEqual(len(attachments), 1)
+        buttons = attachments[0].content.buttons
+        button_count_err = (
+            f"Should have only received {button_count} buttons in multi-turn prompt"
+        )
+
+        if activity.text == self._choose_bird:
+            self.assertEqual(len(buttons), button_count, button_count_err)
+            self.assertEqual(buttons[0].value, self._bald_eagle)
+            self.assertEqual(buttons[1].value, "Hummingbird")
+
+        if activity.text == self.DEFAULT_ACTIVE_LEARNING_TITLE:
+            self.assertEqual(len(buttons), button_count, button_count_err)
+            self.assertEqual(buttons[0].value, "Esper seeks")
+            self.assertEqual(buttons[1].value, "Esper sups")
+            self.assertEqual(buttons[2].value, self.DEFAULT_NO_MATCH_TEXT)
+
+    def _get_json_res(self, text: str) -> object:
+        if text == self._tell_me_about_birds:
+            return QnaMakerDialogTest._get_json_for_file(
+                "QnAMakerDialog_MultiTurn_Answer1.json"
+            )
+
+        if text == self._bald_eagle:
+            return QnaMakerDialogTest._get_json_for_file(
+                "QnAMakerDialog_MultiTurn_Answer2.json"
+            )
+
+        if text == self._esper:
+            return QnaMakerDialogTest._get_json_for_file(
+                "QnAMakerDialog_ActiveLearning.json"
+            )
+
+        return None
+
+    @staticmethod
+    def _get_json_for_file(response_file: str) -> object:
+        curr_dir = path.dirname(path.abspath(__file__))
+        response_path = path.join(curr_dir, "test_data", response_file)
+
+        with open(response_path, "r", encoding="utf-8-sig") as file:
+            response_str = file.read()
+        response_json = json.loads(response_str)
+
+        return response_json