cohit's picture
Upload folder using huggingface_hub
0827183 verified
# 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()