File size: 18,859 Bytes
0827183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
from typing import List

from botbuilder.dialogs import (
    WaterfallDialog,
    WaterfallStepContext,
    DialogContext,
    DialogTurnResult,
    Dialog,
    ObjectPath,
    DialogTurnStatus,
    DialogReason,
)
from botbuilder.schema import Activity, ActivityTypes

from .qnamaker_dialog_options import QnAMakerDialogOptions
from .. import (
    QnAMakerOptions,
    QnADialogResponseOptions,
    QnAMaker,
    QnAMakerEndpoint,
)
from ..models import QnARequestContext, Metadata, QueryResult, FeedbackRecord
from ..models.ranker_types import RankerTypes
from ..utils import QnACardBuilder


class QnAMakerDialog(WaterfallDialog):
    """

    A dialog that supports multi-step and adaptive-learning QnA Maker services.



    .. remarks::

      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.

    """

    KEY_QNA_CONTEXT_DATA = "qnaContextData"
    """

    The path for storing and retrieving QnA Maker context data.



    .. remarks:

      This represents context about the current or previous call to QnA Maker.

      It is stored within the current step's :class:'botbuilder.dialogs.WaterfallStepContext'.

      It supports QnA Maker's follow-up prompt and active learning features.

    """

    KEY_PREVIOUS_QNA_ID = "prevQnAId"
    """

    The path for storing and retrieving the previous question ID.



    .. remarks:

      This represents the QnA question ID from the previous turn.

      It is stored within the current step's :class:'botbuilder.dialogs.WaterfallStepContext'.

      It supports QnA Maker's follow-up prompt and active learning features.

    """

    KEY_OPTIONS = "options"
    """

    The path for storing and retrieving the options for this instance of the dialog.



    .. remarks:

      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 :class:'botbuilder.dialogs.WaterfallStepContext'.

      It supports QnA Maker and the dialog system.

    """

    # Dialog Options parameters
    DEFAULT_THRESHOLD = 0.3
    """ The default threshold for answers returned, based on score. """

    DEFAULT_TOP_N = 3
    """ The default maximum number of answers to be returned for the question. """

    DEFAULT_NO_ANSWER = "No QnAMaker answers found."
    """ The default no answer text sent to the user. """

    # Card parameters
    DEFAULT_CARD_TITLE = "Did you mean:"
    """ The default active learning card title. """

    DEFAULT_CARD_NO_MATCH_TEXT = "None of the above."
    """ The default active learning no match text. """

    DEFAULT_CARD_NO_MATCH_RESPONSE = "Thanks for the feedback."
    """ The default active learning response text. """

    # Value Properties
    PROPERTY_CURRENT_QUERY = "currentQuery"
    PROPERTY_QNA_DATA = "qnaData"

    def __init__(

        self,

        knowledgebase_id: str,

        endpoint_key: str,

        hostname: str,

        no_answer: Activity = None,

        threshold: float = DEFAULT_THRESHOLD,

        active_learning_card_title: str = DEFAULT_CARD_TITLE,

        card_no_match_text: str = DEFAULT_CARD_NO_MATCH_TEXT,

        top: int = DEFAULT_TOP_N,

        card_no_match_response: Activity = None,

        strict_filters: [Metadata] = None,

        dialog_id: str = "QnAMakerDialog",

    ):
        """

        Initializes a new instance of the QnAMakerDialog class.



        :param knowledgebase_id: The ID of the QnA Maker knowledge base to query.

        :param endpoint_key: The QnA Maker endpoint key to use to query the knowledge base.

        :param hostname: The QnA Maker host URL for the knowledge base, starting with "https://" and

        ending with "/qnamaker".

        :param no_answer: The activity to send the user when QnA Maker does not find an answer.

        :param threshold: The threshold for answers returned, based on score.

        :param active_learning_card_title: The card title to use when showing active learning options

        to the user, if active learning is enabled.

        :param card_no_match_text: The button text to use with active learning options,

        allowing a user to indicate none of the options are applicable.

        :param top: The maximum number of answers to return from the knowledge base.

        :param card_no_match_response: The activity to send the user if they select the no match option

        on an active learning card.

        :param strict_filters: QnA Maker metadata with which to filter or boost queries to the

        knowledge base; or null to apply none.

        :param dialog_id: The ID of this dialog.

        """
        super().__init__(dialog_id)

        self.knowledgebase_id = knowledgebase_id
        self.endpoint_key = endpoint_key
        self.hostname = hostname
        self.no_answer = no_answer
        self.threshold = threshold
        self.active_learning_card_title = active_learning_card_title
        self.card_no_match_text = card_no_match_text
        self.top = top
        self.card_no_match_response = card_no_match_response
        self.strict_filters = strict_filters

        self.maximum_score_for_low_score_variation = 0.95

        self.add_step(self.__call_generate_answer)
        self.add_step(self.__call_train)
        self.add_step(self.__check_for_multiturn_prompt)
        self.add_step(self.__display_qna_result)

    async def begin_dialog(

        self, dialog_context: DialogContext, options: object = None

    ) -> DialogTurnResult:
        """

        Called when the dialog is started and pushed onto the dialog stack.



        .. remarks:

          If the task is successful, the result indicates whether the dialog is still

          active after the turn has been processed by the dialog.



        :param dialog_context: The :class:'botbuilder.dialogs.DialogContext' for the current turn of conversation.

        :param options: Optional, initial information to pass to the dialog.

        """

        if not dialog_context:
            raise TypeError("DialogContext is required")

        if (
            dialog_context.context
            and dialog_context.context.activity
            and dialog_context.context.activity.type != ActivityTypes.message
        ):
            return Dialog.end_of_turn

        dialog_options = QnAMakerDialogOptions(
            options=self._get_qnamaker_options(dialog_context),
            response_options=self._get_qna_response_options(dialog_context),
        )

        if options:
            dialog_options = ObjectPath.assign(dialog_options, options)

        ObjectPath.set_path_value(
            dialog_context.active_dialog.state,
            QnAMakerDialog.KEY_OPTIONS,
            dialog_options,
        )

        return await super().begin_dialog(dialog_context, dialog_options)

    def _get_qnamaker_client(self, dialog_context: DialogContext) -> QnAMaker:
        """

        Gets a :class:'botbuilder.ai.qna.QnAMaker' to use to access the QnA Maker knowledge base.



        :param dialog_context: The :class:'botbuilder.dialogs.DialogContext' for the current turn of conversation.

        """

        endpoint = QnAMakerEndpoint(
            endpoint_key=self.endpoint_key,
            host=self.hostname,
            knowledge_base_id=self.knowledgebase_id,
        )

        options = self._get_qnamaker_options(dialog_context)

        return QnAMaker(endpoint, options)

    def _get_qnamaker_options(  # pylint: disable=unused-argument

        self, dialog_context: DialogContext

    ) -> QnAMakerOptions:
        """

        Gets the options for the QnAMaker client that the dialog will use to query the knowledge base.



        :param dialog_context: The :class:'botbuilder.dialogs.DialogContext' for the current turn of conversation.

        """

        return QnAMakerOptions(
            score_threshold=self.threshold,
            strict_filters=self.strict_filters,
            top=self.top,
            context=QnARequestContext(),
            qna_id=0,
            ranker_type=RankerTypes.DEFAULT,
            is_test=False,
        )

    def _get_qna_response_options(  # pylint: disable=unused-argument

        self, dialog_context: DialogContext

    ) -> QnADialogResponseOptions:
        """

        Gets the options the dialog will use to display query results to the user.



        :param dialog_context: The :class:'botbuilder.dialogs.DialogContext' for the current turn of conversation.

        """

        return QnADialogResponseOptions(
            no_answer=self.no_answer,
            active_learning_card_title=self.active_learning_card_title
            or QnAMakerDialog.DEFAULT_CARD_TITLE,
            card_no_match_text=self.card_no_match_text
            or QnAMakerDialog.DEFAULT_CARD_NO_MATCH_TEXT,
            card_no_match_response=self.card_no_match_response,
        )

    async def __call_generate_answer(self, step_context: WaterfallStepContext):
        dialog_options: QnAMakerDialogOptions = ObjectPath.get_path_value(
            step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS
        )

        # Resetting context and QnAId
        dialog_options.options.qna_id = 0
        dialog_options.options.context = QnARequestContext()

        # Storing the context info
        step_context.values[QnAMakerDialog.PROPERTY_CURRENT_QUERY] = (
            step_context.context.activity.text
        )

        # -Check if previous context is present, if yes then put it with the query
        # -Check for id if query is present in reverse index.
        previous_context_data = ObjectPath.get_path_value(
            step_context.active_dialog.state, QnAMakerDialog.KEY_QNA_CONTEXT_DATA, {}
        )
        previous_qna_id = ObjectPath.get_path_value(
            step_context.active_dialog.state, QnAMakerDialog.KEY_PREVIOUS_QNA_ID, 0
        )

        if previous_qna_id > 0:
            dialog_options.options.context = QnARequestContext(
                previous_qna_id=previous_qna_id
            )

            current_qna_id = previous_context_data.get(
                step_context.context.activity.text
            )
            if current_qna_id:
                dialog_options.options.qna_id = current_qna_id

        # Calling QnAMaker to get response.
        qna_client = self._get_qnamaker_client(step_context)
        response = await qna_client.get_answers_raw(
            step_context.context, dialog_options.options
        )

        is_active_learning_enabled = response.active_learning_enabled
        step_context.values[QnAMakerDialog.PROPERTY_QNA_DATA] = response.answers

        # Resetting previous query.
        previous_qna_id = -1
        ObjectPath.set_path_value(
            step_context.active_dialog.state,
            QnAMakerDialog.KEY_PREVIOUS_QNA_ID,
            previous_qna_id,
        )

        # Check if active learning is enabled and send card
        # maximum_score_for_low_score_variation is the score above which no need to check for feedback.
        if (
            response.answers
            and response.answers[0].score <= self.maximum_score_for_low_score_variation
        ):
            # Get filtered list of the response that support low score variation criteria.
            response.answers = qna_client.get_low_score_variation(response.answers)
            if len(response.answers) > 1 and is_active_learning_enabled:
                suggested_questions = [qna.questions[0] for qna in response.answers]
                message = QnACardBuilder.get_suggestions_card(
                    suggested_questions,
                    dialog_options.response_options.active_learning_card_title,
                    dialog_options.response_options.card_no_match_text,
                )
                await step_context.context.send_activity(message)

                ObjectPath.set_path_value(
                    step_context.active_dialog.state,
                    QnAMakerDialog.KEY_OPTIONS,
                    dialog_options,
                )

                await qna_client.close()

                return DialogTurnResult(DialogTurnStatus.Waiting)

        # If card is not shown, move to next step with top qna response.
        result = [response.answers[0]] if response.answers else []
        step_context.values[QnAMakerDialog.PROPERTY_QNA_DATA] = result
        ObjectPath.set_path_value(
            step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS, dialog_options
        )

        await qna_client.close()

        return await step_context.next(result)

    async def __call_train(self, step_context: WaterfallStepContext):
        dialog_options: QnAMakerDialogOptions = ObjectPath.get_path_value(
            step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS
        )
        train_responses: [QueryResult] = step_context.values[
            QnAMakerDialog.PROPERTY_QNA_DATA
        ]
        current_query = step_context.values[QnAMakerDialog.PROPERTY_CURRENT_QUERY]

        reply = step_context.context.activity.text

        if len(train_responses) > 1:
            qna_results = [
                result for result in train_responses if result.questions[0] == reply
            ]

            if qna_results:
                qna_result = qna_results[0]
                step_context.values[QnAMakerDialog.PROPERTY_QNA_DATA] = [qna_result]

                feedback_records = [
                    FeedbackRecord(
                        user_id=step_context.context.activity.id,
                        user_question=current_query,
                        qna_id=qna_result.id,
                    )
                ]

                # Call Active Learning Train API
                qna_client = self._get_qnamaker_client(step_context)
                await qna_client.call_train(feedback_records)
                await qna_client.close()

                return await step_context.next([qna_result])

            if (
                reply.lower()
                == dialog_options.response_options.card_no_match_text.lower()
            ):
                activity = dialog_options.response_options.card_no_match_response
                if not activity:
                    await step_context.context.send_activity(
                        QnAMakerDialog.DEFAULT_CARD_NO_MATCH_RESPONSE
                    )
                else:
                    await step_context.context.send_activity(activity)

                return await step_context.end_dialog()

            return await super().run_step(
                step_context, index=0, reason=DialogReason.BeginCalled, result=None
            )

        return await step_context.next(step_context.result)

    async def __check_for_multiturn_prompt(self, step_context: WaterfallStepContext):
        dialog_options: QnAMakerDialogOptions = ObjectPath.get_path_value(
            step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS
        )

        response = step_context.result
        if response and isinstance(response, List):
            answer = response[0]
            if answer.context and answer.context.prompts:
                previous_context_data = ObjectPath.get_path_value(
                    step_context.active_dialog.state,
                    QnAMakerDialog.KEY_QNA_CONTEXT_DATA,
                    {},
                )
                for prompt in answer.context.prompts:
                    previous_context_data[prompt.display_text] = prompt.qna_id

                ObjectPath.set_path_value(
                    step_context.active_dialog.state,
                    QnAMakerDialog.KEY_QNA_CONTEXT_DATA,
                    previous_context_data,
                )
                ObjectPath.set_path_value(
                    step_context.active_dialog.state,
                    QnAMakerDialog.KEY_PREVIOUS_QNA_ID,
                    answer.id,
                )
                ObjectPath.set_path_value(
                    step_context.active_dialog.state,
                    QnAMakerDialog.KEY_OPTIONS,
                    dialog_options,
                )

                # Get multi-turn prompts card activity.
                message = QnACardBuilder.get_qna_prompts_card(
                    answer, dialog_options.response_options.card_no_match_text
                )
                await step_context.context.send_activity(message)

                return DialogTurnResult(DialogTurnStatus.Waiting)

        return await step_context.next(step_context.result)

    async def __display_qna_result(self, step_context: WaterfallStepContext):
        dialog_options: QnAMakerDialogOptions = ObjectPath.get_path_value(
            step_context.active_dialog.state, QnAMakerDialog.KEY_OPTIONS
        )

        reply = step_context.context.activity.text
        if reply.lower() == dialog_options.response_options.card_no_match_text.lower():
            activity = dialog_options.response_options.card_no_match_response
            if not activity:
                await step_context.context.send_activity(
                    QnAMakerDialog.DEFAULT_CARD_NO_MATCH_RESPONSE
                )
            else:
                await step_context.context.send_activity(activity)

            return await step_context.end_dialog()

        # If previous QnAId is present, replace the dialog
        previous_qna_id = ObjectPath.get_path_value(
            step_context.active_dialog.state, QnAMakerDialog.KEY_PREVIOUS_QNA_ID, 0
        )
        if previous_qna_id > 0:
            return await super().run_step(
                step_context, index=0, reason=DialogReason.BeginCalled, result=None
            )

        # If response is present then show that response, else default answer.
        response = step_context.result
        if response and isinstance(response, List):
            await step_context.context.send_activity(response[0].answer)
        else:
            activity = dialog_options.response_options.no_answer
            if not activity:
                await step_context.context.send_activity(
                    QnAMakerDialog.DEFAULT_NO_ANSWER
                )
            else:
                await step_context.context.send_activity(activity)

        return await step_context.end_dialog()