From 78c67513b7bbc6c3e3c1ae373fa839b548a17b14 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Thu, 11 Apr 2019 22:03:11 -0700 Subject: [PATCH 01/73] adding base of activity handler, addition to a sample pending --- .../botbuilder/core/activity_handler.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 libraries/botbuilder-core/botbuilder/core/activity_handler.py diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py new file mode 100644 index 000000000..b1e1600eb --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -0,0 +1,52 @@ +import asyncio +from botbuilder.schema import ActivityTypes, TurnContext, ChannelAccount + + +class ActivityHandler: + + async def on_turn(self, turn_context: TurnContext): + if turn_context is None: + raise TypeError('ActivityHandler.on_turn(): turn_context cannot be None.') + + if hasattr(turn_context, 'activity') and turn_context.activity is None: + raise TypeError('ActivityHandler.on_turn(): turn_context must have a non-None activity.') + + if hasattr(turn_context.activity, 'type') and turn_context.activity.type is None: + raise TypeError('ActivityHandler.on_turn(): turn_context activity must have a non-None type.') + + return { + ActivityTypes.message: await self.on_message_activity(turn_context), + ActivityTypes.conversation_update: await self.on_conversation_update_activity(turn_context), + ActivityTypes.event: await self.on_event_activity(turn_context) + }.get(turn_context.activity.type, await self.on_unrecognized_activity_type(turn_context)) + + async def on_message_activity(self, turn_context: TurnContext): + return + + async def on_conversation_update_activity(self, turn_context: TurnContext): + if turn_context.activity.members_added is not None and len(turn_context.activity.members_added) > 0: + return await self.on_members_added_activity(turn_context) + elif turn_context.activity.members_removed is not None and len(turn_context.activity.members_removed) > 0: + return await self.on_members_removed_activity(turn_context) + return + + async def on_members_added_activity(self, members_added: ChannelAccount, turn_context: TurnContext): + return + + async def on_members_removed_activity(self, members_removed: ChannelAccount, turn_context: TurnContext): + return + + async def on_event_activity(self, turn_context: TurnContext): + if turn_context.activity.name == 'tokens/response': + return await self.on_token_response_event(turn_context) + + return await self.on_event(turn_context) + + async def on_token_response_event(self, turn_context: TurnContext): + return + + async def on_event(self, turn_context: TurnContext): + return + + async def on_unrecognized_activity_type(self, turn_context: TurnContext): + return From 818e811516fe9105d9e1914eb36802a21729f2e1 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Sun, 14 Apr 2019 15:11:56 -0300 Subject: [PATCH 02/73] initial file structure (empty class files) --- libraries/botbuilder-dialogs/README.rst | 87 +++++++++++++++++++ .../botbuilder/dialogs/__init__.py | 11 +++ .../botbuilder/dialogs/about.py | 12 +++ .../botbuilder/dialogs/choices/channel.py | 0 .../botbuilder/dialogs/choices/choice.py | 0 .../dialogs/choices/choice_factory.py | 0 .../dialogs/choices/choice_factory_options.py | 0 .../botbuilder/dialogs/component_dialog.py | 0 .../botbuilder/dialogs/dialog.py | 0 .../botbuilder/dialogs/dialog_context.py | 0 .../botbuilder/dialogs/dialog_instance.py | 0 .../botbuilder/dialogs/dialog_set.py | 0 .../botbuilder/dialogs/dialog_state.py | 0 .../botbuilder/dialogs/dialog_turn_result.py | 0 .../dialogs/prompts/confirm_prompt.py | 0 .../dialogs/prompts/datetime_prompt.py | 0 .../dialogs/prompts/datetime_resolution.py | 0 .../botbuilder/dialogs/prompts/prompt.py | 0 .../dialogs/prompts/prompt_options.py | 0 .../prompts/prompt_recognizer_result.py | 0 .../dialogs/prompts/prompt_validator.py | 0 .../botbuilder/dialogs/prompts/text_prompt.py | 0 .../botbuilder/dialogs/waterfall_dialog.py | 0 .../botbuilder/dialogs/waterfall_step.py | 0 .../dialogs/waterfall_step_context.py | 0 libraries/botbuilder-dialogs/requirements.txt | 6 ++ libraries/botbuilder-dialogs/setup.cfg | 2 + libraries/botbuilder-dialogs/setup.py | 37 ++++++++ 28 files changed, 155 insertions(+) create mode 100644 libraries/botbuilder-dialogs/README.rst create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/about.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory_options.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py create mode 100644 libraries/botbuilder-dialogs/requirements.txt create mode 100644 libraries/botbuilder-dialogs/setup.cfg create mode 100644 libraries/botbuilder-dialogs/setup.py diff --git a/libraries/botbuilder-dialogs/README.rst b/libraries/botbuilder-dialogs/README.rst new file mode 100644 index 000000000..6dfc82c91 --- /dev/null +++ b/libraries/botbuilder-dialogs/README.rst @@ -0,0 +1,87 @@ + +================================== +BotBuilder-Core SDK for Python +================================== + +.. image:: https://travis-ci.org/Microsoft/botbuilder-python.svg?branch=master + :target: https://travis-ci.org/Microsoft/botbuilder-python + :align: right + :alt: Travis status for master branch +.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/SDK_v4-Python-package-CI?branchName=master + :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/SDK_v4-Python-package-CI + :align: right + :alt: Azure DevOps status for master branch +.. image:: https://badge.fury.io/py/botbuilder-core.svg + :target: https://badge.fury.io/py/botbuilder-core + :alt: Latest PyPI package version + +Within the Bot Framework, BotBuilder-core enables you to build bots that exchange messages with users on channels that are configured in the Bot Framework Portal. + +How to Install +============== + +.. code-block:: python + + pip install botbuilder-core + + +Documentation/Wiki +================== + +You can find more information on the botbuilder-python project by visiting our `Wiki`_. + +Requirements +============ + +* `Python >= 3.6.4`_ + + +Source Code +=========== +The latest developer version is available in a github repository: +https://github.com/Microsoft/botbuilder-python/ + + +Contributing +============ + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the `Microsoft Open Source Code of Conduct`_. +For more information see the `Code of Conduct FAQ`_ or +contact `opencode@microsoft.com`_ with any additional questions or comments. + +Reporting Security Issues +========================= + +Security issues and bugs should be reported privately, via email, to the Microsoft Security +Response Center (MSRC) at `secure@microsoft.com`_. You should +receive a response within 24 hours. If for some reason you do not, please follow up via +email to ensure we received your original message. Further information, including the +`MSRC PGP`_ key, can be found in +the `Security TechCenter`_. + +License +======= + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT_ License. + +.. _Wiki: https://github.com/Microsoft/botbuilder-python/wiki +.. _Python >= 3.6.4: https://www.python.org/downloads/ +.. _MIT: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt +.. _Microsoft Open Source Code of Conduct: https://opensource.microsoft.com/codeofconduct/ +.. _Code of Conduct FAQ: https://opensource.microsoft.com/codeofconduct/faq/ +.. _opencode@microsoft.com: mailto:opencode@microsoft.com +.. _secure@microsoft.com: mailto:secure@microsoft.com +.. _MSRC PGP: https://technet.microsoft.com/en-us/security/dn606155 +.. _Security TechCenter: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt + +.. `_ \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py new file mode 100644 index 000000000..061980ac9 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py @@ -0,0 +1,11 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .about import __version__ + +__all__ = [ + '__version__'] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py new file mode 100644 index 000000000..14b5d97dd --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/about.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = 'botbuilder-dialogs' +__version__ = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.0.0.a6" +__uri__ = 'https://www.github.com/Microsoft/botbuilder-python' +__author__ = 'Microsoft' +__description__ = 'Microsoft Bot Framework Bot Builder' +__summary__ = 'Microsoft Bot Framework Bot Builder SDK for Python.' +__license__ = 'MIT' diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory_options.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt new file mode 100644 index 000000000..eb0ee11a1 --- /dev/null +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -0,0 +1,6 @@ +msrest>=0.6.6 +botframework-connector>=4.0.0.a6 +botbuilder-schema>=4.0.0.a6 +requests>=2.18.1 +PyJWT==1.5.3 +cryptography==2.1.4 \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/setup.cfg b/libraries/botbuilder-dialogs/setup.cfg new file mode 100644 index 000000000..68c61a226 --- /dev/null +++ b/libraries/botbuilder-dialogs/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=0 \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py new file mode 100644 index 000000000..a9c3d4e49 --- /dev/null +++ b/libraries/botbuilder-dialogs/setup.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + 'botbuilder-schema>=4.0.0.a6', + 'botframework-connector>=4.0.0.a6'] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, 'botbuilder', 'core', 'about.py')) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +setup( + name=package_info['__title__'], + version=package_info['__version__'], + url=package_info['__uri__'], + author=package_info['__author__'], + description=package_info['__description__'], + keywords=['BotBuilderCore', 'bots', 'ai', 'botframework', 'botbuilder'], + long_description=package_info['__summary__'], + license=package_info['__license__'], + packages=['botbuilder.core'], + install_requires=REQUIRES, + classifiers=[ + 'Programming Language :: Python :: 3.6', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Development Status :: 3 - Alpha', + 'Topic :: Scientific/Engineering :: Artificial Intelligence', + ] +) From aa3a0721941f208c0dbad673d2f5d7a5e48ed5cf Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Mon, 15 Apr 2019 15:12:31 -0700 Subject: [PATCH 03/73] SAFEKEEPING Checkin to unblock congysu --- .../botbuilder/dialogs/dialog.py | 67 ++++++ .../botbuilder/dialogs/dialog_context.py | 216 ++++++++++++++++++ .../botbuilder/dialogs/dialog_reason.py | 17 ++ 3 files changed, 300 insertions(+) create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py index e69de29bb..dc5f05bcf 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from abc import ABC, abstractmethod +from .dialog_context import DialogContext + +from botbuilder.core.turn_context import TurnContext + + +class Dialog(ABC): + def __init__(self, dialog_id: str): + if dialog_id == None || not dialog_id.strip(): + raise TypeError('Dialog(): dialogId cannot be None.') + + self.telemetry_client = None; # TODO: Make this NullBotTelemetryClient() + self.id = dialog_id; + + @abstractmethod + async def begin_dialog(self, dc: DialogContext, options: object = None): + """ + Method called when a new dialog has been pushed onto the stack and is being activated. + :param dc: The dialog context for the current turn of conversation. + :param options: (Optional) additional argument(s) to pass to the dialog being started. + """ + raise NotImplementedError() + + async def continue_dialog(self, dc: DialogContext): + """ + Method called when an instance of the dialog is the "current" dialog and the + user replies with a new activity. The dialog will generally continue to receive the user's + replies until it calls either `end_dialog()` or `begin_dialog()`. + If this method is NOT implemented then the dialog will automatically be ended when the user replies. + :param dc: The dialog context for the current turn of conversation. + :return: + """ + # By default just end the current dialog. + return await dc.EndDialog(None); + + async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: object): + """ + Method called when an instance of the dialog is being returned to from another + dialog that was started by the current instance using `begin_dialog()`. + If this method is NOT implemented then the dialog will be automatically ended with a call + to `end_dialog()`. Any result passed from the called dialog will be passed + to the current dialog's parent. + :param dc: The dialog context for the current turn of conversation. + :param reason: Reason why the dialog resumed. + :param result: (Optional) value returned from the dialog that was called. The type of the value returned is dependent on the dialog that was called. + :return: + """ + # By default just end the current dialog. + return await dc.EndDialog(result); + + async def reprompt_dialog(self, context: TurnContext, instance: DialogInstance): + """ + :param context: + :return: + """ + # No-op by default + return; + + async def end_dialog(self, context: TurnContext, instance: DialogInstance): + """ + :param context: + :return: + """ + # No-op by default + return; diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index e69de29bb..a8a13aa35 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -0,0 +1,216 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_set import DialogSet +from .dialog_state import DialogState +from .dialog_turn_result import DialogTurnResult +from .dialog_reason import DialogReason +from botbuilder.core.turn_context import TurnContext + + +class DialogContext(): + def __init__(self, dialogs: DialogSet, turn_context: TurnContext, state: DialogState): + if dialogs is None: + raise TypeError('DialogContext(): dialogs cannot be None.') + if turn_context is None: + raise TypeError('DialogContext(): turn_context cannot be None.') + self.__turn_context = turn_context; + self.__dialogs = dialogs; + self.__id = dialog_id; + self.__stack = state.dialog_stack; + self.parent; + + @property + def dialogs(self): + """Gets the set of dialogs that can be called from this context. + + :param: + :return str: + """ + return self.__dialogs; + + @property + def context(self): + """Gets the context for the current turn of conversation. + + :param: + :return str: + """ + return self.__stack; + + @property + def stack(self): + """Gets the current dialog stack. + + :param: + :return str: + """ + return self.__stack; + + + @property + def active_dialog(self): + """Return the container link in the database. + + :param: + :return str: + """ + if (self.__stack && self.__stack.size() > 0): + return self.__stack[0]; + return None; + + + + async def begin_dialog(self, dialog_id: str, options: object = None): + """ + Pushes a new dialog onto the dialog stack. + :param dialog_id: ID of the dialog to start.. + :param options: (Optional) additional argument(s) to pass to the dialog being started. + """ + if (not dialog_id): + raise TypeError('Dialog(): dialogId cannot be None.') + # Look up dialog + dialog = find_dialog(dialog_id); + if (not dialog): + raise Exception("'DialogContext.begin_dialog(): A dialog with an id of '%s' wasn't found." + " The dialog must be included in the current or parent DialogSet." + " For example, if subclassing a ComponentDialog you can call add_dialog() within your constructor." % dialog_id); + # Push new instance onto stack + instance = new DialogInstance(); + { + Id = dialogId, + State = new Dictionary(), + }; + + stack.insert(0, instance); + + # Call dialog's BeginAsync() method + return await dialog.begin_dialog(this, options); + + async def prompt(self, dialog_id: str, options: PromptOptions) -> DialogTurnResult: + """ + Helper function to simplify formatting the options for calling a prompt dialog. This helper will + take a `PromptOptions` argument and then call. + :param dialog_id: ID of the prompt to start. + :param options: Contains a Prompt, potentially a RetryPrompt and if using ChoicePrompt, Choices. + :return: + """ + if (not dialog_id): + raise TypeError('DialogContext.prompt(): dialogId cannot be None.'); + + if (not options): + raise TypeError('DialogContext.prompt(): options cannot be None.'); + + return await begin_dialog(dialog_id, options); + + async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: object): + """ + Continues execution of the active dialog, if there is one, by passing the context object to + its `Dialog.continue_dialog()` method. You can check `turn_context.responded` after the call completes + to determine if a dialog was run and a reply was sent to the user. + :return: + """ + # Check for a dialog on the stack + if not active_dialog: + # Look up dialog + dialog = find_dialog(active_dialog.id); + if not dialog: + raise Exception("DialogContext.continue_dialog(): Can't continue dialog. A dialog with an id of '%s' wasn't found." % active_dialog.id); + + # Continue execution of dialog + return await dialog.continue_dialog(self); + else: + return new DialogTurnResult(DialogTurnStatus.Empty); + + async def end_dialog(self, context: TurnContext, instance: DialogInstance): + """ + Ends a dialog by popping it off the stack and returns an optional result to the dialog's + parent. The parent dialog is the dialog that started the dialog being ended via a call to + either "begin_dialog" or "prompt". + The parent dialog will have its `Dialog.resume_dialog()` method invoked with any returned + result. If the parent dialog hasn't implemented a `resume_dialog()` method then it will be + automatically ended as well and the result passed to its parent. If there are no more + parent dialogs on the stack then processing of the turn will end. + :param result: (Optional) result to pass to the parent dialogs. + :return: + """ + await end_active_dialog(DialogReason.EndCalled); + + # Resume previous dialog + if not active_dialog: + # Look up dialog + dialog = find_dialog(active_dialog.id); + if not dialog: + raise Exception("DialogContext.EndDialogAsync(): Can't resume previous dialog. A dialog with an id of '%s' wasn't found." % active_dialog.id); + + # Return result to previous dialog + return await dialog.resume_dialog(self, DialogReason.EndCalled, result); + else: + return new DialogTurnResult(DialogTurnStatus.Complete, result); + + + async def cancel_all_dialogs(self): + """ + Deletes any existing dialog stack thus cancelling all dialogs on the stack. + :param result: (Optional) result to pass to the parent dialogs. + :return: + """ + if (len(stack) > 0): + while (len(stack) > 0): + await end_active_dialog(DialogReason.CancelCalled); + return DialogTurnResult(DialogTurnStatus.Cancelled); + else: + return DialogTurnResult(DialogTurnStatus.Empty); + + async def find_dialog(self, dialog_id: str) -> Dialog: + """ + If the dialog cannot be found within the current `DialogSet`, the parent `DialogContext` + will be searched if there is one. + :param dialog_id: ID of the dialog to search for. + :return: + """ + dialog = dialogs.find(dialog_id); + if (not dialog && parent != None): + dialog = parent.find_dialog(dialog_id); + return dialog; + + async def replace_dialog(self) -> DialogTurnResult: + """ + Ends the active dialog and starts a new dialog in its place. This is particularly useful + for creating loops or redirecting to another dialog. + :param dialog_id: ID of the dialog to search for. + :param options: (Optional) additional argument(s) to pass to the new dialog. + :return: + """ + # End the current dialog and giving the reason. + await end_active_dialog(DialogReason.ReplaceCalled); + + # Start replacement dialog + return await begin_dialog(dialogId, options); + + async def reprompt_dialog(self): + """ + Calls reprompt on the currently active dialog, if there is one. Used with Prompts that have a reprompt behavior. + :return: + """ + # Check for a dialog on the stack + if active_dialog != None: + # Look up dialog + dialog = find_dialog(active_dialog.id); + if not dialog: + raise Exception("DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'." % active_dialog.id); + + # Ask dialog to re-prompt if supported + await dialog.reprompt_dialog(context, active_dialog); + + async def end_active_dialog(reason: DialogReason): + instance = active_dialog; + if instance != None: + # Look up dialog + dialog = find_dialog(instance.id); + if not dialog: + # Notify dialog of end + await dialog.end_dialog(context, instance, reason); + + # Pop dialog off stack + stack.pop()); \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py new file mode 100644 index 000000000..43b774404 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_reason.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from enum import Enum + +class DialogReason(Enum): + # A dialog is being started through a call to `DialogContext.begin()`. + BeginCalled = 1 + # A dialog is being continued through a call to `DialogContext.continue_dialog()`. + ContinueCalled = 2 + # A dialog ended normally through a call to `DialogContext.end_dialog()`. + EndCalled = 3 + # A dialog is ending because it's being replaced through a call to `DialogContext.replace_dialog()`. + ReplaceCalled = 4 + # A dialog was cancelled as part of a call to `DialogContext.cancel_all_dialogs()`. + CancelCalled = 5 + # A step was advanced through a call to `WaterfallStepContext.next()`. + NextCalled = 6 From 03f5a753e63633c101e96ffe78ffbb0e8660a4cc Mon Sep 17 00:00:00 2001 From: congysu Date: Mon, 15 Apr 2019 16:09:50 -0700 Subject: [PATCH 04/73] port for dialog instance --- .../botbuilder/dialogs/dialog_instance.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index e69de29bb..459338687 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict + + +class DialogInstance: + """ + Tracking information for a dialog on the stack. + """ + + def __init__(self): + self._id: str = None + self._state: Dict[str, object] = {} + + @property + def id(self) -> str: + """Gets the ID of the dialog this instance is for. + + :param: + :return str: + """ + return self._id + + @id.setter + def id(self, value: str) -> None: + """Sets the ID of the dialog this instance is for. + + :param: + :param value: ID of the dialog this instance is for. + :return: + """ + self._id = value + + @property + def state(self) -> Dict[str, object]: + """Gets the instance's persisted state. + + :param: + :return Dict[str, object]: + """ + + return self._state + + @state.setter + def state(self, value: Dict[str, object]) -> None: + """Sets the instance's persisted state. + + :param: + :param value: The instance's persisted state. + :return: + """ + self._state = value From 4a5afc25877d323b56e09f35f51a8f37150a807e Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Tue, 16 Apr 2019 10:40:42 -0700 Subject: [PATCH 05/73] Add unittest test, fix setup.py, misc stubs to get initial test running --- .../core/state_property_accessor.py | 40 ++++++++++++ .../botbuilder/core/state_property_info.py | 9 +++ .../botbuilder/dialogs/__init__.py | 10 ++- .../botbuilder/dialogs/dialog.py | 23 ++++--- .../botbuilder/dialogs/dialog_context.py | 40 ++++++------ .../botbuilder/dialogs/dialog_set.py | 65 +++++++++++++++++++ .../botbuilder/dialogs/dialog_state.py | 13 ++++ .../botbuilder/dialogs/dialog_turn_result.py | 18 +++++ .../botbuilder/dialogs/dialog_turn_status.py | 16 +++++ libraries/botbuilder-dialogs/requirements.txt | 1 + libraries/botbuilder-dialogs/setup.py | 10 +-- .../tests/test_dialog_set.py | 19 ++++++ 12 files changed, 231 insertions(+), 33 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/state_property_accessor.py create mode 100644 libraries/botbuilder-core/botbuilder/core/state_property_info.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py create mode 100644 libraries/botbuilder-dialogs/tests/test_dialog_set.py diff --git a/libraries/botbuilder-core/botbuilder/core/state_property_accessor.py b/libraries/botbuilder-core/botbuilder/core/state_property_accessor.py new file mode 100644 index 000000000..27011ef1a --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/state_property_accessor.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from copy import copy +from abc import ABC, abstractmethod +from typing import Callable, List + +from .turn_context import TurnContext + + +class StatePropertyAccessor(ABC): + @abstractmethod + async def get(self, turnContext: TurnContext, default_value_factory = None): + """ + Get the property value from the source + :param turn_context: Turn Context. + :param default_value_factory: Function which defines the property value to be returned if no value has been set. + + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def delete(self, turnContext: TurnContext): + """ + Saves store items to storage. + :param turn_context: Turn Context. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def set(self, turnContext: TurnContext, value): + """ + Set the property value on the source. + :param turn_context: Turn Context. + :return: + """ + raise NotImplementedError() + diff --git a/libraries/botbuilder-core/botbuilder/core/state_property_info.py b/libraries/botbuilder-core/botbuilder/core/state_property_info.py new file mode 100644 index 000000000..279099c8f --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/state_property_info.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC + +class StatePropertyAccessor(ABC): + @property + def name(self): + raise NotImplementedError(); \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py index 061980ac9..5cb6fb51b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py @@ -7,5 +7,13 @@ from .about import __version__ -__all__ = [ +from .dialog_context import DialogContext +from .dialog import Dialog +from .dialog_set import DialogSet +from .dialog_state import DialogState + +__all__ = ['Dialog', + 'DialogContext', + 'DialogSet', + 'DialogState', '__version__'] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py index dc5f05bcf..d985beebf 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py @@ -1,21 +1,25 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. from abc import ABC, abstractmethod -from .dialog_context import DialogContext from botbuilder.core.turn_context import TurnContext +from .dialog_reason import DialogReason class Dialog(ABC): def __init__(self, dialog_id: str): - if dialog_id == None || not dialog_id.strip(): + if dialog_id == None or not dialog_id.strip(): raise TypeError('Dialog(): dialogId cannot be None.') self.telemetry_client = None; # TODO: Make this NullBotTelemetryClient() - self.id = dialog_id; + self.__id = dialog_id; + + @property + def id(self): + return self.__id; @abstractmethod - async def begin_dialog(self, dc: DialogContext, options: object = None): + async def begin_dialog(self, dc, options: object = None): """ Method called when a new dialog has been pushed onto the stack and is being activated. :param dc: The dialog context for the current turn of conversation. @@ -23,7 +27,7 @@ async def begin_dialog(self, dc: DialogContext, options: object = None): """ raise NotImplementedError() - async def continue_dialog(self, dc: DialogContext): + async def continue_dialog(self, dc): """ Method called when an instance of the dialog is the "current" dialog and the user replies with a new activity. The dialog will generally continue to receive the user's @@ -35,7 +39,7 @@ async def continue_dialog(self, dc: DialogContext): # By default just end the current dialog. return await dc.EndDialog(None); - async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: object): + async def resume_dialog(self, dc, reason: DialogReason, result: object): """ Method called when an instance of the dialog is being returned to from another dialog that was started by the current instance using `begin_dialog()`. @@ -50,15 +54,16 @@ async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: o # By default just end the current dialog. return await dc.EndDialog(result); - async def reprompt_dialog(self, context: TurnContext, instance: DialogInstance): + # TODO: instance is DialogInstance + async def reprompt_dialog(self, context: TurnContext, instance): """ :param context: :return: """ # No-op by default return; - - async def end_dialog(self, context: TurnContext, instance: DialogInstance): + # TODO: instance is DialogInstance + async def end_dialog(self, context: TurnContext, instance): """ :param context: :return: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index a8a13aa35..b95f54119 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -1,17 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .dialog_set import DialogSet + from .dialog_state import DialogState from .dialog_turn_result import DialogTurnResult from .dialog_reason import DialogReason +from .dialog import Dialog from botbuilder.core.turn_context import TurnContext - class DialogContext(): - def __init__(self, dialogs: DialogSet, turn_context: TurnContext, state: DialogState): + def __init__(self, dialog_set: object, turn_context: TurnContext, state: DialogState): if dialogs is None: raise TypeError('DialogContext(): dialogs cannot be None.') + # TODO: Circular dependency with dialog_set: Check type. if turn_context is None: raise TypeError('DialogContext(): turn_context cannot be None.') self.__turn_context = turn_context; @@ -55,7 +56,7 @@ def active_dialog(self): :param: :return str: """ - if (self.__stack && self.__stack.size() > 0): + if (self.__stack and self.__stack.size() > 0): return self.__stack[0]; return None; @@ -76,18 +77,17 @@ async def begin_dialog(self, dialog_id: str, options: object = None): " The dialog must be included in the current or parent DialogSet." " For example, if subclassing a ComponentDialog you can call add_dialog() within your constructor." % dialog_id); # Push new instance onto stack - instance = new DialogInstance(); - { - Id = dialogId, - State = new Dictionary(), - }; + instance = DialogInstance() + instance.id = dialog_id + instance.state = [] stack.insert(0, instance); # Call dialog's BeginAsync() method return await dialog.begin_dialog(this, options); - async def prompt(self, dialog_id: str, options: PromptOptions) -> DialogTurnResult: + # TODO: Fix options: PromptOptions instead of object + async def prompt(self, dialog_id: str, options) -> DialogTurnResult: """ Helper function to simplify formatting the options for calling a prompt dialog. This helper will take a `PromptOptions` argument and then call. @@ -103,7 +103,8 @@ async def prompt(self, dialog_id: str, options: PromptOptions) -> DialogTurnResu return await begin_dialog(dialog_id, options); - async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: object): + + async def continue_dialog(self, dc, reason: DialogReason, result: object): """ Continues execution of the active dialog, if there is one, by passing the context object to its `Dialog.continue_dialog()` method. You can check `turn_context.responded` after the call completes @@ -120,9 +121,10 @@ async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: # Continue execution of dialog return await dialog.continue_dialog(self); else: - return new DialogTurnResult(DialogTurnStatus.Empty); + return DialogTurnResult(DialogTurnStatus.Empty); - async def end_dialog(self, context: TurnContext, instance: DialogInstance): + # TODO: instance is DialogInstance + async def end_dialog(self, context: TurnContext, instance): """ Ends a dialog by popping it off the stack and returns an optional result to the dialog's parent. The parent dialog is the dialog that started the dialog being ended via a call to @@ -146,7 +148,7 @@ async def end_dialog(self, context: TurnContext, instance: DialogInstance): # Return result to previous dialog return await dialog.resume_dialog(self, DialogReason.EndCalled, result); else: - return new DialogTurnResult(DialogTurnStatus.Complete, result); + return DialogTurnResult(DialogTurnStatus.Complete, result); async def cancel_all_dialogs(self): @@ -170,7 +172,7 @@ async def find_dialog(self, dialog_id: str) -> Dialog: :return: """ dialog = dialogs.find(dialog_id); - if (not dialog && parent != None): + if (not dialog and parent != None): dialog = parent.find_dialog(dialog_id); return dialog; @@ -201,16 +203,16 @@ async def reprompt_dialog(self): raise Exception("DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'." % active_dialog.id); # Ask dialog to re-prompt if supported - await dialog.reprompt_dialog(context, active_dialog); + await dialog.reprompt_dialog(context, active_dialog) async def end_active_dialog(reason: DialogReason): instance = active_dialog; if instance != None: # Look up dialog - dialog = find_dialog(instance.id); + dialog = find_dialog(instance.id) if not dialog: # Notify dialog of end - await dialog.end_dialog(context, instance, reason); + await dialog.end_dialog(context, instance, reason) # Pop dialog off stack - stack.pop()); \ No newline at end of file + stack.pop() \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index e69de29bb..61784c911 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +from .dialog_state import DialogState +from .dialog_turn_result import DialogTurnResult +from .dialog_reason import DialogReason +from botbuilder.core.turn_context import TurnContext +from botbuilder.core.state_property_accessor import StatePropertyAccessor + + + +class DialogSet(): + from .dialog import Dialog + from .dialog_context import DialogContext + + def __init__(self, dialog_state: StatePropertyAccessor): + if dialog_state is None: + raise TypeError('DialogSet(): dialog_state cannot be None.') + self._dialog_state = dialog_state + # self.__telemetry_client = NullBotTelemetryClient.Instance; + + self._dialogs = [] + + + async def add(self, dialog: Dialog): + """ + Adds a new dialog to the set and returns the added dialog. + :param dialog: The dialog to add. + """ + if not dialog: + raise TypeError('DialogSet(): dialog cannot be None.') + + if dialog.id in self._dialogs: + raise TypeError("DialogSet.Add(): A dialog with an id of '%s' already added." % dialog.id) + + # dialog.telemetry_client = this._telemetry_client; + _dialogs[dialog.id] = dialog + + return self + + async def create_context(self, turn_context: TurnContext) -> DialogContext: + BotAssert.context_not_null(turn_context) + + if not _dialog_state: + raise RuntimeError("DialogSet.CreateContextAsync(): DialogSet created with a null IStatePropertyAccessor.") + + state = await _dialog_state.get(turn_context, lambda: DialogState()) + + return DialogContext(self, turn_context, state) + + async def find(self, dialog_id: str) -> Dialog: + """ + Finds a dialog that was previously added to the set using add(dialog) + :param dialog_id: ID of the dialog/prompt to look up. + :return: The dialog if found, otherwise null. + """ + if (not dialog_id): + raise TypeError('DialogContext.find(): dialog_id cannot be None.'); + + if dialog_id in _dialogs: + return _dialogs[dialog_id] + + return None + diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index e69de29bb..1003970c3 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +class DialogState(): + + def __init__(self, stack: []): + if stack is None: + raise TypeError('DialogState(): stack cannot be None.') + self.__dialog_stack = stack + + @property + def dialog_stack(self): + return __dialog_stack; diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index e69de29bb..5d0058d91 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_turn_status import DialogTurnStatus + +class DialogTurnResult(): + + def __init__(self, status: DialogTurnStatus, result:object = None): + self.__status = status + self.__result = result; + + @property + def status(self): + return __status; + + @property + def result(self): + return __result; diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py new file mode 100644 index 000000000..425303703 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_status.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from enum import Enum + +class DialogTurnStatus(Enum): + # Indicates that there is currently nothing on the dialog stack. + Empty = 1 + + # Indicates that the dialog on top is waiting for a response from the user. + Waiting = 2 + + # Indicates that the dialog completed successfully, the result is available, and the stack is empty. + Complete = 3 + + # Indicates that the dialog was cancelled and the stack is empty. + Cancelled = 4 diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index eb0ee11a1..0ca626ebf 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -1,6 +1,7 @@ msrest>=0.6.6 botframework-connector>=4.0.0.a6 botbuilder-schema>=4.0.0.a6 +botbuilder-core>=4.0.0.a6 requests>=2.18.1 PyJWT==1.5.3 cryptography==2.1.4 \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index a9c3d4e49..c524d4310 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -6,11 +6,12 @@ REQUIRES = [ 'botbuilder-schema>=4.0.0.a6', - 'botframework-connector>=4.0.0.a6'] + 'botframework-connector>=4.0.0.a6', + 'botbuilder-core>=4.0.0.a6'] root = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(root, 'botbuilder', 'core', 'about.py')) as f: +with open(os.path.join(root, 'botbuilder', 'dialogs', 'about.py')) as f: package_info = {} info = f.read() exec(info, package_info) @@ -21,11 +22,12 @@ url=package_info['__uri__'], author=package_info['__author__'], description=package_info['__description__'], - keywords=['BotBuilderCore', 'bots', 'ai', 'botframework', 'botbuilder'], + keywords=['BotBuilderDialogs', 'bots', 'ai', 'botframework', 'botbuilder'], long_description=package_info['__summary__'], license=package_info['__license__'], - packages=['botbuilder.core'], + packages=['botbuilder.dialogs'], install_requires=REQUIRES, + include_package_data=True, classifiers=[ 'Programming Language :: Python :: 3.6', 'Intended Audience :: Developers', diff --git a/libraries/botbuilder-dialogs/tests/test_dialog_set.py b/libraries/botbuilder-dialogs/tests/test_dialog_set.py new file mode 100644 index 000000000..0fa027f5c --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/test_dialog_set.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +import unittest +from botbuilder.core import BotAdapter +from botbuilder.dialogs import DialogSet + + +class DialogSetTests(unittest.TestCase): + def DialogSet_ConstructorValid(): + storage = MemoryStorage(); + convoState = ConversationState(storage); + dialogStateProperty = convoState.create_property("dialogstate"); + ds = DialogSet(dialogStateProperty); + + def DialogSet_ConstructorNullProperty(): + ds = DialogSet(null); + From f30ec6d17dd678f5579e3077fa5ae4ee0f1ac5de Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Tue, 16 Apr 2019 15:11:47 -0700 Subject: [PATCH 06/73] Make dialog_set test work --- .../botbuilder/core/bot_state.py | 97 +++++++++++++++++-- .../botbuilder/core/conversation_state.py | 13 +-- .../botbuilder/core/turn_context.py | 8 ++ .../botbuilder/dialogs/dialog_context.py | 56 +++++------ .../tests/test_dialog_set.py | 18 ++-- 5 files changed, 142 insertions(+), 50 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 02918a90e..0ca689e52 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -4,20 +4,66 @@ from .turn_context import TurnContext from .middleware_set import Middleware from .storage import calculate_change_hash, StoreItem, StorageKeyFactory, Storage +from .property_manager import PropertyManager +from botbuilder.core.state_property_accessor import StatePropertyAccessor +from botbuilder.core import turn_context +from _ast import Try -class BotState(Middleware): - def __init__(self, storage: Storage, storage_key: StorageKeyFactory): +class BotState(PropertyManager): + def __init__(self, storage: Storage, context_service_key: str): self.state_key = 'state' self.storage = storage - self.storage_key = storage_key + self._context_storage_key = context_service_key + + - async def on_process_request(self, context, next_middleware): + def create_property(self, name:str) -> StatePropertyAccessor: + """Create a property definition and register it with this BotState. + Parameters + ---------- + name + The name of the property. + + Returns + ------- + StatePropertyAccessor + If successful, the state property accessor created. """ - Reads and writes state for your bot to storage. - :param context: - :param next_middleware: - :return: + if not name: + raise TypeError('BotState.create_property(): BotState cannot be None.') + return BotStatePropertyAccessor(self, name); + + + async def load(self, turn_context: TurnContext, force: bool = False): + """Reads in the current state object and caches it in the context object for this turm. + Parameters + ---------- + turn_context + The context object for this turn. + force + Optional. True to bypass the cache. + """ + if not turn_context: + raise TypeError('BotState.load(): turn_context cannot be None.') + cached_state = turn_context.turn_state.get(self._context_storage_key) + storage_key = get_storage_key(turn_context) + if (force or not cached_state or not cached_state.state) : + items = await _storage.read([storage_key]) + val = items.get(storage_key) + turn_context.turn_state[self._context_storage_key] = CachedBotState(val) + + async def on_process_request(self, context, next_middleware): + """Reads and writes state for your bot to storage. + Parameters + ---------- + context + The Turn Context. + next_middleware + The next middleware component + + Returns + ------- """ await self.read(context, True) # For libraries like aiohttp, the web.Response need to be bubbled up from the process_activity logic, which is @@ -37,7 +83,7 @@ async def read(self, context: TurnContext, force: bool=False): cached = context.services.get(self.state_key) if force or cached is None or ('state' in cached and cached['state'] is None): - key = self.storage_key(context) + key = self._context_storage_key(context) items = await self.storage.read([key]) state = items.get(key, StoreItem()) hash_state = calculate_change_hash(state) @@ -57,7 +103,7 @@ async def write(self, context: TurnContext, force: bool=False): cached = context.services.get(self.state_key) if force or (cached is not None and cached.get('hash', None) != calculate_change_hash(cached['state'])): - key = self.storage_key(context) + key = self._context_storage_key(context) if cached is None: cached = {'state': StoreItem(e_tag='*'), 'hash': ''} @@ -89,3 +135,34 @@ async def get(self, context: TurnContext): if isinstance(cached, dict) and isinstance(cached['state'], StoreItem): state = cached['state'] return state + + +class BotStatePropertyAccessor(StatePropertyAccessor): + def __init__(self, bot_state: BotState, name: str): + self._bot_state = bot_state + self._name = name + + @property + def name(self) -> str: + return _name; + + async def delete(self, turn_context: TurnContext): + await self._bot_state.load(turn_context, False) + await self._bot_state.delete_property_value(turn_context, name) + + async def get(self, turn_context: TurnContext, default_value_factory): + await self._bot_state.load(turn_context, false) + try: + return await _bot_state.get_property_value(turn_context, name) + except: + # ask for default value from factory + if not default_value_factory: + return None + result = default_value_factory() + # save default value for any further calls + await set(turn_context, result) + return result + + async def set(self, turn_context: TurnContext, value): + await _bot_state.load(turn_context, false) + await _bot_state.set_property_value(turn_context, name) diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index a1684dbe6..f2c37dc81 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -7,19 +7,20 @@ class ConversationState(BotState): - """ + """Conversation State Reads and writes conversation state for your bot to storage. """ no_key_error_message = 'ConversationState: channelId and/or conversation missing from context.activity.' def __init__(self, storage: Storage, namespace: str=''): + """Creates a new ConversationState instance. + Parameters + ---------- + storage : Storage + Where to store + namespace: str """ - Creates a new ConversationState instance. - :param storage: - :param namespace: - """ - def call_get_storage_key(context): key = self.get_storage_key(context) if key is None: diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 2bc6bbba1..a0563e865 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -31,6 +31,14 @@ def __init__(self, adapter_or_context, request: Activity=None): raise TypeError('TurnContext must be instantiated with an adapter.') if self.activity is None: raise TypeError('TurnContext must be instantiated with a request parameter of type Activity.') + + # TODO: Make real turn-state-collection + self.turn_state = [] + + + @property + def turn_state(self): + self.turn_state def copy_to(self, context: 'TurnContext') -> None: """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index b95f54119..a65c193a6 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -28,16 +28,16 @@ def dialogs(self): :param: :return str: """ - return self.__dialogs; + return self.__dialogs @property - def context(self): + def context(self) -> TurnContext: """Gets the context for the current turn of conversation. :param: :return str: """ - return self.__stack; + return self.__turn_context @property def stack(self): @@ -46,7 +46,7 @@ def stack(self): :param: :return str: """ - return self.__stack; + return self.__stack @property @@ -57,8 +57,8 @@ def active_dialog(self): :return str: """ if (self.__stack and self.__stack.size() > 0): - return self.__stack[0]; - return None; + return self.__stack[0] + return None @@ -81,10 +81,10 @@ async def begin_dialog(self, dialog_id: str, options: object = None): instance.id = dialog_id instance.state = [] - stack.insert(0, instance); + stack.insert(0, instance) # Call dialog's BeginAsync() method - return await dialog.begin_dialog(this, options); + return await dialog.begin_dialog(this, options) # TODO: Fix options: PromptOptions instead of object async def prompt(self, dialog_id: str, options) -> DialogTurnResult: @@ -96,12 +96,12 @@ async def prompt(self, dialog_id: str, options) -> DialogTurnResult: :return: """ if (not dialog_id): - raise TypeError('DialogContext.prompt(): dialogId cannot be None.'); + raise TypeError('DialogContext.prompt(): dialogId cannot be None.') if (not options): - raise TypeError('DialogContext.prompt(): options cannot be None.'); + raise TypeError('DialogContext.prompt(): options cannot be None.') - return await begin_dialog(dialog_id, options); + return await begin_dialog(dialog_id, options) async def continue_dialog(self, dc, reason: DialogReason, result: object): @@ -114,14 +114,14 @@ async def continue_dialog(self, dc, reason: DialogReason, result: object): # Check for a dialog on the stack if not active_dialog: # Look up dialog - dialog = find_dialog(active_dialog.id); + dialog = find_dialog(active_dialog.id) if not dialog: - raise Exception("DialogContext.continue_dialog(): Can't continue dialog. A dialog with an id of '%s' wasn't found." % active_dialog.id); + raise Exception("DialogContext.continue_dialog(): Can't continue dialog. A dialog with an id of '%s' wasn't found." % active_dialog.id) # Continue execution of dialog - return await dialog.continue_dialog(self); + return await dialog.continue_dialog(self) else: - return DialogTurnResult(DialogTurnStatus.Empty); + return DialogTurnResult(DialogTurnStatus.Empty) # TODO: instance is DialogInstance async def end_dialog(self, context: TurnContext, instance): @@ -141,14 +141,14 @@ async def end_dialog(self, context: TurnContext, instance): # Resume previous dialog if not active_dialog: # Look up dialog - dialog = find_dialog(active_dialog.id); + dialog = find_dialog(active_dialog.id) if not dialog: - raise Exception("DialogContext.EndDialogAsync(): Can't resume previous dialog. A dialog with an id of '%s' wasn't found." % active_dialog.id); + raise Exception("DialogContext.EndDialogAsync(): Can't resume previous dialog. A dialog with an id of '%s' wasn't found." % active_dialog.id) # Return result to previous dialog - return await dialog.resume_dialog(self, DialogReason.EndCalled, result); + return await dialog.resume_dialog(self, DialogReason.EndCalled, result) else: - return DialogTurnResult(DialogTurnStatus.Complete, result); + return DialogTurnResult(DialogTurnStatus.Complete, result) async def cancel_all_dialogs(self): @@ -159,10 +159,10 @@ async def cancel_all_dialogs(self): """ if (len(stack) > 0): while (len(stack) > 0): - await end_active_dialog(DialogReason.CancelCalled); - return DialogTurnResult(DialogTurnStatus.Cancelled); + await end_active_dialog(DialogReason.CancelCalled) + return DialogTurnResult(DialogTurnStatus.Cancelled) else: - return DialogTurnResult(DialogTurnStatus.Empty); + return DialogTurnResult(DialogTurnStatus.Empty) async def find_dialog(self, dialog_id: str) -> Dialog: """ @@ -173,8 +173,8 @@ async def find_dialog(self, dialog_id: str) -> Dialog: """ dialog = dialogs.find(dialog_id); if (not dialog and parent != None): - dialog = parent.find_dialog(dialog_id); - return dialog; + dialog = parent.find_dialog(dialog_id) + return dialog async def replace_dialog(self) -> DialogTurnResult: """ @@ -185,10 +185,10 @@ async def replace_dialog(self) -> DialogTurnResult: :return: """ # End the current dialog and giving the reason. - await end_active_dialog(DialogReason.ReplaceCalled); + await end_active_dialog(DialogReason.ReplaceCalled) # Start replacement dialog - return await begin_dialog(dialogId, options); + return await begin_dialog(dialogId, options) async def reprompt_dialog(self): """ @@ -198,9 +198,9 @@ async def reprompt_dialog(self): # Check for a dialog on the stack if active_dialog != None: # Look up dialog - dialog = find_dialog(active_dialog.id); + dialog = find_dialog(active_dialog.id) if not dialog: - raise Exception("DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'." % active_dialog.id); + raise Exception("DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'." % active_dialog.id) # Ask dialog to re-prompt if supported await dialog.reprompt_dialog(context, active_dialog) diff --git a/libraries/botbuilder-dialogs/tests/test_dialog_set.py b/libraries/botbuilder-dialogs/tests/test_dialog_set.py index 0fa027f5c..d81e8fe34 100644 --- a/libraries/botbuilder-dialogs/tests/test_dialog_set.py +++ b/libraries/botbuilder-dialogs/tests/test_dialog_set.py @@ -5,15 +5,21 @@ import unittest from botbuilder.core import BotAdapter from botbuilder.dialogs import DialogSet +from botbuilder.core import MemoryStorage, ConversationState +from botbuilder.core.state_property_accessor import StatePropertyAccessor class DialogSetTests(unittest.TestCase): - def DialogSet_ConstructorValid(): + def test_DialogSet_ConstructorValid(self): storage = MemoryStorage(); - convoState = ConversationState(storage); - dialogStateProperty = convoState.create_property("dialogstate"); - ds = DialogSet(dialogStateProperty); + conv = ConversationState(storage) + accessor = conv.create_property("dialogstate") + ds = DialogSet(accessor); + self.assertNotEqual(ds, None) - def DialogSet_ConstructorNullProperty(): - ds = DialogSet(null); + def test_DialogSet_ConstructorNoneProperty(self): + self.assertRaises(TypeError, lambda:DialogSet(None)) + + + From e83a3926eac754b88cddbd797315c23e1d0b3223 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Tue, 16 Apr 2019 16:30:06 -0700 Subject: [PATCH 07/73] Add PropertyManager --- .../botbuilder-core/botbuilder/core/property_manager.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 libraries/botbuilder-core/botbuilder/core/property_manager.py diff --git a/libraries/botbuilder-core/botbuilder/core/property_manager.py b/libraries/botbuilder-core/botbuilder/core/property_manager.py new file mode 100644 index 000000000..0cd2097e0 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/property_manager.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from botbuilder.core.state_property_accessor import StatePropertyAccessor + +class PropertyManager: + def create_property(self, name: str) -> StatePropertyAccessor: + raise NotImplementedError() + From 1420622d2a71976c2279eb08756ce8d18cca7f4d Mon Sep 17 00:00:00 2001 From: congysu Date: Tue, 16 Apr 2019 16:01:00 -0700 Subject: [PATCH 08/73] Port channel for choices and channels for connector * add docstring with google/pytorch style * add initial test with unittest --- .../botbuilder/dialogs/choices/__init__.py | 10 ++ .../botbuilder/dialogs/choices/channel.py | 106 ++++++++++++++++++ .../botbuilder-dialogs/tests/__init__.py | 0 .../tests/choices/__init__.py | 0 .../tests/choices/test_channel.py | 13 +++ .../botframework/connector/__init__.py | 3 +- .../botframework/connector/channels.py | 56 +++++++++ 7 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py create mode 100644 libraries/botbuilder-dialogs/tests/__init__.py create mode 100644 libraries/botbuilder-dialogs/tests/choices/__init__.py create mode 100644 libraries/botbuilder-dialogs/tests/choices/test_channel.py create mode 100644 libraries/botframework-connector/botframework/connector/channels.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py new file mode 100644 index 000000000..affec204a --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py @@ -0,0 +1,10 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .channel import Channel + +__all__ = ["Channel"] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py index e69de29bb..999d1c42d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import TurnContext +from botframework.connector import Channels + + +class Channel(object): + """ + Methods for determining channel specific functionality. + """ + + @staticmethod + def supports_suggested_actions(channel_id: str, button_cnt: int = 100) -> bool: + """Determine if a number of Suggested Actions are supported by a Channel. + + Args: + channel_id (str): The Channel to check the if Suggested Actions are supported in. + button_cnt (int, optional): Defaults to 100. The number of Suggested Actions to check for the Channel. + + Returns: + bool: True if the Channel supports the button_cnt total Suggested Actions, False if the Channel does not support that number of Suggested Actions. + """ + + max_actions = { + # https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies + Channels.Facebook: 10, + Channels.Skype: 10, + # https://developers.line.biz/en/reference/messaging-api/#items-object + Channels.Line: 13, + # https://dev.kik.com/#/docs/messaging#text-response-object + Channels.Kik: 20, + Channels.Telegram: 100, + Channels.Slack: 100, + Channels.Emulator: 100, + Channels.Directline: 100, + Channels.Webchat: 100, + } + return button_cnt <= max_actions[channel_id] if channel_id in max_actions else False + + @staticmethod + def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: + """Determine if a number of Card Actions are supported by a Channel. + + Args: + channel_id (str): The Channel to check if the Card Actions are supported in. + button_cnt (int, optional): Defaults to 100. The number of Card Actions to check for the Channel. + + Returns: + bool: True if the Channel supports the button_cnt total Card Actions, False if the Channel does not support that number of Card Actions. + """ + + max_actions = { + Channels.Facebook: 3, + Channels.Skype: 3, + Channels.Msteams: 3, + Channels.Line: 99, + Channels.Slack: 100, + Channels.Emulator: 100, + Channels.Directline: 100, + Channels.Webchat: 100, + Channels.Cortana: 100, + } + return button_cnt <= max_actions[channel_id] if channel_id in max_actions else False + + @staticmethod + def has_message_feed(channel_id: str) -> bool: + """Determine if a Channel has a Message Feed. + + Args: + channel_id (str): The Channel to check for Message Feed. + + Returns: + bool: True if the Channel has a Message Feed, False if it does not. + """ + + return False if channel_id == Channels.Cortana else True + + @staticmethod + def max_action_title_length(channel_id: str) -> int: + """Maximum length allowed for Action Titles. + + Args: + channel_id (str): The Channel to determine Maximum Action Title Length. + + Returns: + int: The total number of characters allowed for an Action Title on a specific Channel. + """ + + return 20 + + @staticmethod + def get_channel_id(turn_context: TurnContext) -> str: + """Get the Channel Id from the current Activity on the Turn Context. + + Args: + turn_context (TurnContext): The Turn Context to retrieve the Activity's Channel Id from. + + Returns: + str: The Channel Id from the Turn Context's Activity. + """ + + if turn_context.activity.channelId is None: + return "" + else: + return turn_context.activity.channelId diff --git a/libraries/botbuilder-dialogs/tests/__init__.py b/libraries/botbuilder-dialogs/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/tests/choices/__init__.py b/libraries/botbuilder-dialogs/tests/choices/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/tests/choices/test_channel.py b/libraries/botbuilder-dialogs/tests/choices/test_channel.py new file mode 100644 index 000000000..88d7ff37e --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/choices/test_channel.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest + +from botbuilder.dialogs.choices import Channel +from botframework.connector import Channels + + +class ChannelTest(unittest.TestCase): + def test_supports_suggested_actions(self): + actual = Channel.supports_suggested_actions(Channels.Facebook, 5) + self.assertTrue(actual) diff --git a/libraries/botframework-connector/botframework/connector/__init__.py b/libraries/botframework-connector/botframework/connector/__init__.py index cdd4bf038..6b4ad11c3 100644 --- a/libraries/botframework-connector/botframework/connector/__init__.py +++ b/libraries/botframework-connector/botframework/connector/__init__.py @@ -9,10 +9,11 @@ # regenerated. # -------------------------------------------------------------------------- +from .channels import Channels from .connector_client import ConnectorClient from .version import VERSION -__all__ = ['ConnectorClient'] +__all__ = ["Channels", "ConnectorClient"] __version__ = VERSION diff --git a/libraries/botframework-connector/botframework/connector/channels.py b/libraries/botframework-connector/botframework/connector/channels.py new file mode 100644 index 000000000..9c7d56e5b --- /dev/null +++ b/libraries/botframework-connector/botframework/connector/channels.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class Channels(object): + """ + Ids of channels supported by the Bot Builder. + """ + + Console = "console" + """Console channel.""" + + Cortana = "cortana" + """Cortana channel.""" + + Directline = "directline" + """Direct Line channel.""" + + Email = "email" + """Email channel.""" + + Emulator = "emulator" + """Emulator channel.""" + + Facebook = "facebook" + """Facebook channel.""" + + Groupme = "groupme" + """Group Me channel.""" + + Kik = "kik" + """Kik channel.""" + + Line = "line" + """Line channel.""" + + Msteams = "msteams" + """MS Teams channel.""" + + Skype = "skype" + """Skype channel.""" + + Skypeforbusiness = "skypeforbusiness" + """Skype for Business channel.""" + + Slack = "slack" + """Slack channel.""" + + Sms = "sms" + """SMS (Twilio) channel.""" + + Telegram = "telegram" + """Telegram channel.""" + + Webchat = "webchat" + """WebChat channel.""" From 814cbf9bbebcb0e341d14dc352ec48915279232a Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Wed, 17 Apr 2019 10:26:37 -0700 Subject: [PATCH 09/73] Add WaterfallDialog initial checkin --- .../botbuilder/dialogs/dialog.py | 5 +- .../botbuilder/dialogs/dialog_set.py | 8 +- .../botbuilder/dialogs/waterfall_dialog.py | 103 ++++++++++++++++++ .../dialogs/waterfall_step_context.py | 15 +++ libraries/botbuilder-dialogs/requirements.txt | 3 +- .../botbuilder-dialogs/tests/__init__.py | 2 + .../tests/test_waterfall.py | 97 +++++++++++++++++ 7 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 libraries/botbuilder-dialogs/tests/test_waterfall.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py index d985beebf..b5f6132e9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py @@ -4,9 +4,12 @@ from botbuilder.core.turn_context import TurnContext from .dialog_reason import DialogReason - +from .dialog_turn_status import DialogTurnStatus +from .dialog_turn_result import DialogTurnResult class Dialog(ABC): + end_of_turn = DialogTurnResult(DialogTurnStatus.Waiting); + def __init__(self, dialog_id: str): if dialog_id == None or not dialog_id.strip(): raise TypeError('Dialog(): dialogId cannot be None.') diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index 61784c911..be977ee98 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -7,7 +7,7 @@ from .dialog_reason import DialogReason from botbuilder.core.turn_context import TurnContext from botbuilder.core.state_property_accessor import StatePropertyAccessor - +from typing import Dict class DialogSet(): @@ -20,7 +20,7 @@ def __init__(self, dialog_state: StatePropertyAccessor): self._dialog_state = dialog_state # self.__telemetry_client = NullBotTelemetryClient.Instance; - self._dialogs = [] + self._dialogs: Dict[str, object] = {} async def add(self, dialog: Dialog): @@ -35,7 +35,7 @@ async def add(self, dialog: Dialog): raise TypeError("DialogSet.Add(): A dialog with an id of '%s' already added." % dialog.id) # dialog.telemetry_client = this._telemetry_client; - _dialogs[dialog.id] = dialog + self._dialogs[dialog.id] = dialog return self @@ -45,7 +45,7 @@ async def create_context(self, turn_context: TurnContext) -> DialogContext: if not _dialog_state: raise RuntimeError("DialogSet.CreateContextAsync(): DialogSet created with a null IStatePropertyAccessor.") - state = await _dialog_state.get(turn_context, lambda: DialogState()) + state = await self._dialog_state.get(turn_context, lambda: DialogState()) return DialogContext(self, turn_context, state) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index e69de29bb..d5c1f8a15 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations # For PEP563 +import uuid +from typing import Dict +from .dialog_reason import DialogReason +from .dialog import Dialog +from botbuilder.dialogs.dialog_turn_result import DialogTurnResult + +class WaterfallDialog(Dialog): + PersistedOptions = "options" + StepIndex = "stepIndex" + PersistedValues = "values" + PersistedInstanceId = "instanceId" + + def __init__(self, dialog_id: str, steps: [] = None): + super(WaterfallDialog, self).__init__(dialog_id) + if not steps: + self._steps = [] + else: + self._steps = steps + + # TODO: Add WaterfallStep class + def add_step(self, step) -> WaterfallDialog: + """Adds a new step to the waterfall. + Parameters + ---------- + step + Step to add + + Returns + ------- + WaterfallDialog + Waterfall dialog for fluent calls to `add_step()`. + """ + if not step: + raise TypeError('WaterfallDialog.add_step(): step cannot be None.') + + async def begin_dialog(self, dc: DialogContext, options: object = None) -> DialogTurnResult: + + if not dc: + raise TypeError('WaterfallDialog.begin_dialog(): dc cannot be None.') + + # Initialize waterfall state + state = dc.active_dialog.state + instance_id = uuid.uuid1().__str__() + state[PersistedOptions] = options + state[PersistedValues] = Dict[str, object] + state[PersistedInstanceId] = instanceId + + properties = Dict[str, object] + properties['dialog_id'] = id + properties['instance_id'] = instance_id + + # Run first stepkinds + return await run_step(dc, 0, DialogReason.BeginCalled, None) + + async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: Object) -> DialogTurnResult: + if not dc: + raise TypeError('WaterfallDialog.continue_dialog(): dc cannot be None.') + + if dc.context.activity.type != ActivityTypes.message: + return Dialog.end_of_turn + + return await resume_dialog(dc, DialogReason.ContinueCalled, dc.context.activity.text) + + async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: object): + if not dc: + raise TypeError('WaterfallDialog.resume_dialog(): dc cannot be None.') + + # Increment step index and run step + state = dc.active_dialog.state + + # Future Me: + # If issues with CosmosDB, see https://github.com/Microsoft/botbuilder-dotnet/issues/871 + # for hints. + return run_step(dc, state[StepIndex] + 1, reason, result) + + async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, reason: DialogReason): + # TODO: Add telemetry logging + return + + async def on_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # TODO: Add telemetry logging + return await self._steps[step_context.index](step_context) + + async def run_steps(self, dc: DialogContext, index: int, reason: DialogReason, result: Object) -> DialogTurnResult: + if not dc: + raise TypeError('WaterfallDialog.run_steps(): dc cannot be None.') + if index < _steps.size: + # Update persisted step index + state = dc.active_dialog.state + state[StepIndex] = index + + # Create step context + options = state[PersistedOptions] + values = state[PersistedValues] + step_context = WaterfallStepContext(self, dc, options, values, index, reason, result) + return await on_step(step_context) + else: + # End of waterfall so just return any result to parent + return dc.end_dialog(result) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py index e69de29bb..b53febd70 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.dialog_context import DialogContext + +class WaterfallStepContext(DialogContext): + + def __init__(self, stack: []): + if stack is None: + raise TypeError('DialogState(): stack cannot be None.') + self.__dialog_stack = stack + + @property + def dialog_stack(self): + return __dialog_stack; diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index 0ca626ebf..fff4592d9 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -4,4 +4,5 @@ botbuilder-schema>=4.0.0.a6 botbuilder-core>=4.0.0.a6 requests>=2.18.1 PyJWT==1.5.3 -cryptography==2.1.4 \ No newline at end of file +cryptography==2.1.4 +asynciounittest==1.1.0 \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/tests/__init__.py b/libraries/botbuilder-dialogs/tests/__init__.py index e69de29bb..81e13df46 100644 --- a/libraries/botbuilder-dialogs/tests/__init__.py +++ b/libraries/botbuilder-dialogs/tests/__init__.py @@ -0,0 +1,2 @@ +# pylint: disable=missing-docstring +__import__('pkg_resources').declare_namespace(__name__) diff --git a/libraries/botbuilder-dialogs/tests/test_waterfall.py b/libraries/botbuilder-dialogs/tests/test_waterfall.py new file mode 100644 index 000000000..f8c0565da --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/test_waterfall.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +import aiounittest +from botbuilder.core.test_adapter import TestAdapter, TestFlow +from botbuilder.core.memory_storage import MemoryStorage +from botbuilder.core.conversation_state import ConversationState +from botbuilder.dialogs.dialog_set import DialogSet +from botbuilder.dialogs.waterfall_dialog import WaterfallDialog +from botbuilder.dialogs.waterfall_step_context import WaterfallStepContext +from botbuilder.dialogs.dialog_turn_result import DialogTurnResult + +async def Waterfall2_Step1(step_context: WaterfallStepContext) -> DialogTurnResult: + await step_context.context.send_activity("step1") + return Dialog.end_of_turn + +async def Waterfall2_Step2(step_context: WaterfallStepContext) -> DialogTurnResult: + await step_context.context.send_activity("step2") + return Dialog.end_of_turn + +async def Waterfall2_Step3(step_context: WaterfallStepContext) -> DialogTurnResult: + await step_context.context.send_activity("step3") + return Dialog.end_of_turn + +class MyWaterfallDialog(WaterfallDialog): + def __init__(self, id: str): + super(WaterfallDialog, self).__init__(id) + self.add_step(Waterfall2_Step1) + self.add_step(Waterfall2_Step2) + self.add_step(Waterfall2_Step3) + + +class WaterfallTests(aiounittest.AsyncTestCase): + def test_waterfall_none_name(self): + self.assertRaises(TypeError, (lambda:WaterfallDialog(None))) + + def test_watterfall_add_none_step(self): + waterfall = WaterfallDialog("test") + self.assertRaises(TypeError, (lambda:waterfall.add_step(None))) + + async def test_waterfall_callback(self): + convo_state = ConversationState(MemoryStorage()) + adapter = TestAdapter() + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + async def step_callback1(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step1") + async def step_callback2(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step2") + async def step_callback3(step: WaterfallStepContext) -> DialogTurnResult: + await step.context.send_activity("step3") + + steps = [step_callback1, step_callback2, step_callback3] + await dialogs.add(WaterfallDialog("test", steps)) + self.assertNotEqual(dialogs, None) + self.assertEqual(len(dialogs._dialogs), 1) + + # TODO: Fix TestFlow + + + async def test_waterfall_with_class(self): + convo_state = ConversationState(MemoryStorage()) + adapter = TestAdapter() + # TODO: Fix Autosave Middleware + dialog_state = convo_state.create_property("dialogState") + dialogs = DialogSet(dialog_state) + + await dialogs.add(MyWaterfallDialog("test")) + self.assertNotEqual(dialogs, None) + self.assertEqual(len(dialogs._dialogs), 1) + + # TODO: Fix TestFlow + + def test_waterfall_prompt(self): + convo_state = ConversationState(MemoryStorage()) + adapter = TestAdapter() + # TODO: Fix Autosave Middleware + # TODO: Fix TestFlow + + def test_waterfall_nested(self): + convo_state = ConversationState(MemoryStorage()) + adapter = TestAdapter() + # TODO: Fix Autosave Middleware + # TODO: Fix TestFlow + + def test_datetimeprompt_first_invalid_then_valid_input(self): + convo_state = ConversationState(MemoryStorage()) + adapter = TestAdapter() + # TODO: Fix Autosave Middleware + # TODO: Fix TestFlow + + + + + + From 478c4ed19c222de40fd509f5fdd861138cb83542 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Wed, 17 Apr 2019 14:51:32 -0700 Subject: [PATCH 10/73] Base prompt stuff --- .../botbuilder/dialogs/prompts/prompt.py | 166 ++++++++++++++++++ .../dialogs/prompts/prompt_options.py | 113 ++++++++++++ .../prompts/prompt_recognizer_result.py | 46 +++++ .../tests/test_prompt_validator_context.py | 28 +++ 4 files changed, 353 insertions(+) create mode 100644 libraries/botbuilder-dialogs/tests/test_prompt_validator_context.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index e69de29bb..6ae21bdb1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.dialog_turn_result import DialogTurnResult +from botbuilder.dialogs.dialog_context import DialogContext +from .prompt_options import PromptOptions +from botbuilder.schema.connector_client_enums import InputHints, ActivityTypes +from botbuilder.dialogs.dialog_reason import DialogReason +from botbuilder.core.turn_context import TurnContext +from botbuilder.dialogs.dialog_instance import DialogInstance +from abc import abstractmethod +from botbuilder.schema.activity import Activity + +""" Base class for all prompts. +""" +class Prompt(Dialog): + persisted_options = "options"; + persisted_state = "state"; + def __init__(self, dialog_id: str, validator: Object = None): + """Creates a new Prompt instance. + Parameters + ---------- + dialog_id + Unique ID of the prompt within its parent `DialogSet` or + `ComponentDialog`. + validator + (Optional) custom validator used to provide additional validation and + re-prompting logic for the prompt. + """ + super(Prompt, self).__init__(str) + + self._validator = validator; + + async def begin_dialog(self, dc: DialogContext, options: Object) -> DialogTurnResult: + if not dc: + raise TypeError('Prompt(): dc cannot be None.') + if not options is PromptOptions: + raise TypeError('Prompt(): Prompt options are required for Prompt dialogs.') + # Ensure prompts have input hint set + if options.prompt != None and not options.prompt.input_hint: + options.prompt.input_hint = InputHints.expecting_input + + if options.RetryPrompt != None and not options.prompt.input_hint: + options.retry_prompt.input_hint = InputHints.expecting_input; + + # Initialize prompt state + state = dc.active_dialog.state; + state[persisted_options] = options; + state[persisted_state] = Dict[str, Object] + + # Send initial prompt + await on_prompt(dc.context, state[persisted_state], state[persisted_options], False) + + return Dialog.end_of_turn + + async def continue_dialog(self, dc: DialogContext): + if not dc: + raise TypeError('Prompt(): dc cannot be None.') + + # Don't do anything for non-message activities + if dc.context.activity.type != ActivityTypes.message: + return Dialog.end_of_turn; + + # Perform base recognition + instance = dc.active_dialog + state = instance.state[persisted_state] + options = instance.State[persisted_options] + recognized = await on_recognize(dc.context, state, options) + + # Validate the return value + is_valid = False; + if _validator != None: + prompt_context = PromptValidatorContext(dc.Context, recognized, state, options) + is_valid = await _validator(promptContext) + options.number_of_attempts += 1 + else: + if recognized.succeeded: + isValid = True + # Return recognized value or re-prompt + if is_valid: + return await dc.end_dialog(recognized.value) + else: + if not dc.context.responded: + await on_prompt(dc.context, state, options, true) + return Dialog.end_of_turn; + + async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: Object) -> DialogTurnResult: + # Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs + # on top of the stack which will result in the prompt receiving an unexpected call to + # dialog_resume() when the pushed on dialog ends. + # To avoid the prompt prematurely ending we need to implement this method and + # simply re-prompt the user. + await reprompt_dialog(dc.context, dc.active_dialog) + return Dialog.end_of_turn + + async def reprompt_dialog(self, turn_context: TurnContext, instance: DialogInstance): + state = instance.state[persisted_state] + options = instance.state[persisted_options] + await on_prompt(turn_context, state, options, False) + + @abstractmethod + async def on_prompt(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions, is_retry: bool): + pass + + @abstractmethod + async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions): + pass + + # TODO: Fix choices to use Choice object when ported. + # TODO: Fix style to use ListStyle when ported. + # TODO: Fix options to use ChoiceFactoryOptions object when ported. + def append_choices(self, prompt: Activity, channel_id: str, choices: object, style: object, options : object ) -> Activity: + # Get base prompt text (if any) + text = prompt.text if prompt != None and not prompt.text == False else '' + + # Create temporary msg + # TODO: fix once ChoiceFactory complete + def inline() -> Activity: + # return ChoiceFactory.inline(choices, text, null, options) + return None + def list() -> Activity: + # return ChoiceFactory.list(choices, text, null, options) + return None + def suggested_action() -> Activity: + # return ChoiceFactory.suggested_action(choices, text) + return None + def hero_card() -> Activity: + # return ChoiceFactory.hero_card(choices, text) + return None + def list_style_none() -> Activity: + activity = Activity() + activity.text = text; + return activity; + def default() -> Activity: + # return ChoiceFactory.for_channel(channel_id, choices, text, None, options); + return None + switcher = { + # ListStyle.inline + 1: inline, + 2: list, + 3: suggested_action, + 4: hero_card, + 5: list_style_none + } + + msg = switcher.get(style, default)() + + # Update prompt with text, actions and attachments + if not prompt: + # clone the prompt the set in the options (note ActivityEx has Properties so this is the safest mechanism) + prompt = copy(prompt); + + prompt.text = msg.text; + + if (msg.suggested_actions != None and msg.suggested_actions.actions != None + and len(msg.suggested_actions.actions) > 0): + prompt.suggested_actions = msg.suggested_actions + + if msg.attachments != None and len(msg.attachments) > 0: + prompt.attachments = msg.attachments; + + return prompt; + else: + # TODO: Update to InputHints.ExpectingInput; + msg.input_hint = None + return msg; \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py index e69de29bb..7007b60d5 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.schema.activity import Activity + +class PromptOptions: + def __init__(self): + self._prompt: Activity = None + self._retry_prompt: Activity = None + # TODO: Replace with Choice Object once ported + self._choices: [] = None + # TODO: Replace with ListStyle Object once ported + self._style: Object = None + self._validations: Object = None + self._number_of_attempts: int = 0 + + @property + def prompt(self) -> Activity: + """Gets the initial prompt to send the user as Activity. + """ + return self._prompt + + @id.setter + def prompt(self, value: Activity) -> None: + """Sets the initial prompt to send the user as Activity. + Parameters + ---------- + value + The new value of the initial prompt. + """ + self._prompt = value + + @property + def retry_prompt(self) -> Activity: + """Gets the retry prompt to send the user as Activity. + """ + return self._retry_prompt + + @id.setter + def retry_prompt(self, value: Activity) -> None: + """Sets the retry prompt to send the user as Activity. + Parameters + ---------- + value + The new value of the retry prompt. + """ + self._retry_prompt = value + + @property + def choices(self) -> Object: + """Gets the list of choices associated with the prompt. + """ + return self._choices + + @id.setter + def choices(self, value: Object) -> None: + """Sets the list of choices associated with the prompt. + Parameters + ---------- + value + The new list of choices associated with the prompt. + """ + self._choices = value + + @property + def style(self) -> Object: + """Gets the ListStyle for a ChoicePrompt. + """ + return self._style + + @id.setter + def style(self, value: Object) -> None: + """Sets the ListStyle for a ChoicePrompt. + Parameters + ---------- + value + The new ListStyle for a ChoicePrompt. + """ + self._style = value + + @property + def validations(self) -> Object: + """Gets additional validation rules to pass the prompts validator routine. + """ + return self._validations + + @id.setter + def validations(self, value: Object) -> None: + """Sets additional validation rules to pass the prompts validator routine. + Parameters + ---------- + value + Additional validation rules to pass the prompts validator routine. + """ + self._validations = value + + @property + def number_of_attempts(self) -> int: + """Gets the count of the number of times the prompt has retried. + """ + return self._number_of_attempts + + @id.setter + def number_of_attempts(self, value: int) -> None: + """Sets the count of the number of times the prompt has retried. + Parameters + ---------- + value + Count of the number of times the prompt has retried. + """ + self._number_of_attempts = value + + diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py index e69de29bb..0dd919594 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" Result returned by a prompts recognizer function. +""" +class PromptRecognizerResult(): + def __init__(self): + """Creates result returned by a prompts recognizer function. + """ + self._succeeded : bool = False + self._value : Object = None + + @property + def succeeded(self) -> bool: + """Gets a bool indicating whether the users utterance was successfully recognized + """ + return self._succeeded + + @id.setter + def succeeded(self, value: bool) -> None: + """Sets the whether the users utterance was successfully recognized + Parameters + ---------- + value + A bool indicating whether the users utterance was successfully recognized + """ + self._succeeded = value + + @property + def value(self) -> Object: + """Gets the value that was recognized if succeeded is `True` + """ + return self._value + + @id.setter + def value(self, value: Object) -> None: + """Sets the value that was recognized (if succeeded is `True`) + Parameters + ---------- + value + The value that was recognized + """ + self._value = value + + + diff --git a/libraries/botbuilder-dialogs/tests/test_prompt_validator_context.py b/libraries/botbuilder-dialogs/tests/test_prompt_validator_context.py new file mode 100644 index 000000000..731dec1ec --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/test_prompt_validator_context.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import aiounittest +from botbuilder.core import BotAdapter +from botbuilder.dialogs import DialogSet +from botbuilder.core import MemoryStorage, ConversationState +from botbuilder.core.state_property_accessor import StatePropertyAccessor + + +class PromptValidatorContextTests(aiounittest.AsyncTestCase): + async def test_prompt_validator_context_end(self): + storage = MemoryStorage(); + conv = ConversationState(storage) + accessor = conv.create_property("dialogstate") + ds = DialogSet(accessor); + self.assertNotEqual(ds, None) + # TODO: Add TestFlow + + def test_prompt_validator_context_retry_end(self): + storage = MemoryStorage(); + conv = ConversationState(storage) + accessor = conv.create_property("dialogstate") + ds = DialogSet(accessor); + self.assertNotEqual(ds, None) + # TODO: Add TestFlow + + # All require Testflow! \ No newline at end of file From cb434bd4c0b92ba6e9874815e5a0433d13daabc3 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Wed, 17 Apr 2019 14:53:12 -0700 Subject: [PATCH 11/73] Miss a few files --- .../botbuilder/dialogs/prompts/__init__.py | 12 +++ .../prompts/prompt_validator_context.py | 73 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py new file mode 100644 index 000000000..5422a0458 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py @@ -0,0 +1,12 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .prompt import Prompt +from .prompt_options import PromptOptions + +__all__ = ["Prompt", + "PromptOptions"] \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py new file mode 100644 index 000000000..36b62aa93 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Dict +from botbuilder.core.turn_context import TurnContext +from .prompt_options import PromptOptions +from .prompt_recognizer_result import PromptRecognizerResult + + +""" Contextual information passed to a custom `PromptValidator`. +""" +class PromptValidatorContext(): + def __init__(self, turn_context: TurnContext, recognized: PromptRecognizerResult, state: Dict[str, Object], options: PromptOptions): + """Creates contextual information passed to a custom `PromptValidator`. + Parameters + ---------- + turn_context + The context for the current turn of conversation with the user. + + recognized + Result returned from the prompts recognizer function. + + state + A dictionary of values persisted for each conversational turn while the prompt is active. + + options + Original set of options passed to the prompt by the calling dialog. + + """ + self._context = turn_context; + self._recognized = recognized + self._state = state + self._options = options + + @property + def context(self) -> TurnContext: + """ The context for the current turn of conversation with the user. + + Note + ---- + The validator can use this to re-prompt the user. + """ + return self._context + + @property + def recognized(self) -> PromptRecognizerResult: + """Result returned from the prompts recognizer function. + + Note + ---- + The `prompt.recognized.succeeded` field can be checked to determine of the recognizer found + anything and then the value can be retrieved from `prompt.recognized.value`. + """ + return self._recognized + + @property + def state(self) -> Dict: + """A dictionary of values persisted for each conversational turn while the prompt is active. + + Note + ---- + The validator can use this to persist things like turn counts or other state information. + """ + return self._recognized + + @property + def options(self) -> PromptOptions: + """Original set of options passed to the prompt by the calling dialog. + + Note + ---- + The validator can extend this interface to support additional prompt options. + """ + return self._options From 3d01cb1cb090cbcd89da81035baf5e3d02606ed1 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Wed, 17 Apr 2019 15:00:49 -0700 Subject: [PATCH 12/73] fix package spelling! --- libraries/botbuilder-dialogs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index fff4592d9..ed292dde5 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -5,4 +5,4 @@ botbuilder-core>=4.0.0.a6 requests>=2.18.1 PyJWT==1.5.3 cryptography==2.1.4 -asynciounittest==1.1.0 \ No newline at end of file +aiounittest==1.1.0 \ No newline at end of file From aa43ad209bd1eafd26f1b54a53da32e4eab0f407 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Wed, 17 Apr 2019 15:11:15 -0700 Subject: [PATCH 13/73] Target Python 3.6 - remove reference to PEP which does not exist --- .../botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index d5c1f8a15..02ce7131a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from __future__ import annotations # For PEP563 + import uuid from typing import Dict from .dialog_reason import DialogReason @@ -22,7 +22,7 @@ def __init__(self, dialog_id: str, steps: [] = None): self._steps = steps # TODO: Add WaterfallStep class - def add_step(self, step) -> WaterfallDialog: + def add_step(self, step): """Adds a new step to the waterfall. Parameters ---------- From 20571b0b2edebe6eefd3b2593c07eb02ab940159 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Wed, 17 Apr 2019 15:26:50 -0700 Subject: [PATCH 14/73] Add aiounittest as formal dependency (in setup.py) --- libraries/botbuilder-dialogs/requirements.txt | 2 +- libraries/botbuilder-dialogs/setup.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/requirements.txt b/libraries/botbuilder-dialogs/requirements.txt index ed292dde5..212fd9e1e 100644 --- a/libraries/botbuilder-dialogs/requirements.txt +++ b/libraries/botbuilder-dialogs/requirements.txt @@ -5,4 +5,4 @@ botbuilder-core>=4.0.0.a6 requests>=2.18.1 PyJWT==1.5.3 cryptography==2.1.4 -aiounittest==1.1.0 \ No newline at end of file +aiounittest>=1.1.0 \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index c524d4310..b987d538f 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -5,6 +5,7 @@ from setuptools import setup REQUIRES = [ + 'aiounittest>=1.1.0', 'botbuilder-schema>=4.0.0.a6', 'botframework-connector>=4.0.0.a6', 'botbuilder-core>=4.0.0.a6'] From bb78153f6c39eeae3c9d11a6a6c7ba4700c9589a Mon Sep 17 00:00:00 2001 From: congysu Date: Wed, 17 Apr 2019 15:02:16 -0700 Subject: [PATCH 15/73] Port ChoiceFactoryOptions --- .../botbuilder/dialogs/choices/__init__.py | 3 +- .../dialogs/choices/choice_factory_options.py | 130 ++++++++++++++++++ .../choices/test_choice_factory_options.py | 32 +++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 libraries/botbuilder-dialogs/tests/choices/test_choice_factory_options.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py index affec204a..5610bb86b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py @@ -6,5 +6,6 @@ # -------------------------------------------------------------------------- from .channel import Channel +from .choice_factory_options import ChoiceFactoryOptions -__all__ = ["Channel"] +__all__ = ["Channel", "ChoiceFactoryOptions"] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory_options.py index e69de29bb..5b4ce1537 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory_options.py @@ -0,0 +1,130 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class ChoiceFactoryOptions(object): + def __init__( + self, + inline_separator: str = None, + inline_or: str = None, + inline_or_more: str = None, + include_numbers: bool = None, + ) -> None: + """Initializes a new instance. + Refer to the code in the ConfirmPrompt for an example of usage. + + :param object: + :type object: + :param inline_separator: The inline seperator value, defaults to None + :param inline_separator: str, optional + :param inline_or: The inline or value, defaults to None + :param inline_or: str, optional + :param inline_or_more: The inline or more value, defaults to None + :param inline_or_more: str, optional + :param includeNumbers: Flag indicating whether to include numbers as a choice, defaults to None + :param includeNumbers: bool, optional + :return: + :rtype: None + """ + + self._inline_separator = inline_separator + self._inline_or = inline_or + self._inline_or_more = inline_or_more + self._include_numbers = include_numbers + + @property + def inline_separator(self) -> str: + """ + Gets the character used to separate individual choices when there are more than 2 choices. + The default value is `", "`. This is optional. + + Returns: + str: The character used to separate individual choices when there are more than 2 choices. + """ + + return self._inline_separator + + @inline_separator.setter + def inline_separator(self, value: str) -> None: + """Sets the character used to separate individual choices when there are more than 2 choices. + The default value is `", "`. This is optional. + + :param value: The character used to separate individual choices when there are more than 2 choices. + :type value: str + :return: + :rtype: None + """ + + self._inline_separator = value + + @property + def inline_or(self) -> str: + """Gets the separator inserted between the choices when their are only 2 choices. The default + value is `" or "`. This is optional. + + :return: The separator inserted between the choices when their are only 2 choices. + :rtype: str + """ + + return self._inline_or + + @inline_or.setter + def inline_or(self, value: str) -> None: + """Sets the separator inserted between the choices when their are only 2 choices. The default + value is `" or "`. This is optional. + + :param value: The separator inserted between the choices when their are only 2 choices. + :type value: str + :return: + :rtype: None + """ + + self._inline_or = value + + @property + def inline_or_more(self) -> str: + """Gets the separator inserted between the last 2 choices when their are more than 2 choices. + The default value is `", or "`. This is optional. + + :return: The separator inserted between the last 2 choices when their are more than 2 choices. + :rtype: str + """ + return self._inline_or_more + + @inline_or_more.setter + def inline_or_more(self, value: str) -> None: + """Sets the separator inserted between the last 2 choices when their are more than 2 choices. + The default value is `", or "`. This is optional. + + :param value: The separator inserted between the last 2 choices when their are more than 2 choices. + :type value: str + :return: + :rtype: None + """ + + self._inline_or_more = value + + @property + def include_numbers(self) -> bool: + """Gets a value indicating whether an inline and list style choices will be prefixed with the index of the + choice as in "1. choice". If , the list style will use a bulleted list instead.The default value is . + + :return: A trueif an inline and list style choices will be prefixed with the index of the + choice as in "1. choice"; otherwise a false and the list style will use a bulleted list instead. + :rtype: bool + """ + return self._include_numbers + + @include_numbers.setter + def include_numbers(self, value: bool) -> None: + """Sets a value indicating whether an inline and list style choices will be prefixed with the index of the + choice as in "1. choice". If , the list style will use a bulleted list instead.The default value is . + + :param value: A trueif an inline and list style choices will be prefixed with the index of the + choice as in "1. choice"; otherwise a false and the list style will use a bulleted list instead. + :type value: bool + :return: + :rtype: None + """ + + self._include_numbers = value diff --git a/libraries/botbuilder-dialogs/tests/choices/test_choice_factory_options.py b/libraries/botbuilder-dialogs/tests/choices/test_choice_factory_options.py new file mode 100644 index 000000000..5ebd9f532 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/choices/test_choice_factory_options.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest + +from botbuilder.dialogs.choices import ChoiceFactoryOptions + + +class ChoiceFactoryOptionsTest(unittest.TestCase): + def test_inline_separator_round_trips(self) -> None: + choice_factor_options = ChoiceFactoryOptions() + expected = ", " + choice_factor_options.inline_separator = expected + self.assertEqual(expected, choice_factor_options.inline_separator) + + def test_inline_or_round_trips(self) -> None: + choice_factor_options = ChoiceFactoryOptions() + expected = " or " + choice_factor_options.inline_or = expected + self.assertEqual(expected, choice_factor_options.inline_or) + + def test_inline_or_more_round_trips(self) -> None: + choice_factor_options = ChoiceFactoryOptions() + expected = ", or " + choice_factor_options.inline_or_more = expected + self.assertEqual(expected, choice_factor_options.inline_or_more) + + def test_include_numbers_round_trips(self) -> None: + choice_factor_options = ChoiceFactoryOptions() + expected = True + choice_factor_options.include_numbers = expected + self.assertEqual(expected, choice_factor_options.include_numbers) From 6229bdb1a16ca46601ed71efffc5d4db70e16793 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Wed, 17 Apr 2019 15:35:21 -0700 Subject: [PATCH 16/73] Fix 3.6-specific issues --- .../botbuilder/dialogs/waterfall_dialog.py | 11 ++++++++--- .../botbuilder/dialogs/waterfall_step.py | 5 +++++ libraries/botbuilder-dialogs/tests/test_waterfall.py | 1 + 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index 02ce7131a..8b71e3b35 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -6,7 +6,12 @@ from typing import Dict from .dialog_reason import DialogReason from .dialog import Dialog -from botbuilder.dialogs.dialog_turn_result import DialogTurnResult +from .dialog_turn_result import DialogTurnResult +from .dialog_context import DialogContext +from .dialog_instance import DialogInstance +from .waterfall_step_context import WaterfallStepContext +from botbuilder.core import TurnContext + class WaterfallDialog(Dialog): PersistedOptions = "options" @@ -56,7 +61,7 @@ async def begin_dialog(self, dc: DialogContext, options: object = None) -> Dialo # Run first stepkinds return await run_step(dc, 0, DialogReason.BeginCalled, None) - async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: Object) -> DialogTurnResult: + async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: object) -> DialogTurnResult: if not dc: raise TypeError('WaterfallDialog.continue_dialog(): dc cannot be None.') @@ -85,7 +90,7 @@ async def on_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: # TODO: Add telemetry logging return await self._steps[step_context.index](step_context) - async def run_steps(self, dc: DialogContext, index: int, reason: DialogReason, result: Object) -> DialogTurnResult: + async def run_steps(self, dc: DialogContext, index: int, reason: DialogReason, result: object) -> DialogTurnResult: if not dc: raise TypeError('WaterfallDialog.run_steps(): dc cannot be None.') if index < _steps.size: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step.py index e69de29bb..b2da0993c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step.py @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# TODO: Remove this file once we get some tests to verify waterfall_step +# unnecessary in Python. \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/tests/test_waterfall.py b/libraries/botbuilder-dialogs/tests/test_waterfall.py index f8c0565da..bc380b88e 100644 --- a/libraries/botbuilder-dialogs/tests/test_waterfall.py +++ b/libraries/botbuilder-dialogs/tests/test_waterfall.py @@ -10,6 +10,7 @@ from botbuilder.dialogs.waterfall_dialog import WaterfallDialog from botbuilder.dialogs.waterfall_step_context import WaterfallStepContext from botbuilder.dialogs.dialog_turn_result import DialogTurnResult +from botbuilder.dialogs.dialog_context import DialogContext async def Waterfall2_Step1(step_context: WaterfallStepContext) -> DialogTurnResult: await step_context.context.send_activity("step1") From 918cb3741a1b6abaa2c3deedff9a48850b293187 Mon Sep 17 00:00:00 2001 From: congysu Date: Wed, 17 Apr 2019 15:43:12 -0700 Subject: [PATCH 17/73] Add Choice --- .../botbuilder/dialogs/choices/__init__.py | 3 +- .../botbuilder/dialogs/choices/choice.py | 75 +++++++++++++++++++ .../tests/choices/test_choice.py | 28 +++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 libraries/botbuilder-dialogs/tests/choices/test_choice.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py index 5610bb86b..90589c39c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py @@ -6,6 +6,7 @@ # -------------------------------------------------------------------------- from .channel import Channel +from .choice import Choice from .choice_factory_options import ChoiceFactoryOptions -__all__ = ["Channel", "ChoiceFactoryOptions"] +__all__ = ["Channel", "Choice", "ChoiceFactoryOptions"] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py index e69de29bb..64317e6bb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.schema import CardAction + + +class Choice(object): + def __init__(self, value: str = None): + self._value = value + self._action = None + self._synonyms = None + + @property + def value(self) -> str: + """Gets the value to return when selected. + + :return: The value to return when selected. + :rtype: str + """ + return self._value + + @value.setter + def value(self, value: str) -> None: + """Sets the value to return when selected. + + :param value: The value to return when selected. + :type value: str + :return: + :rtype: None + """ + self._value = value + + @property + def action(self) -> CardAction: + """Gets the action to use when rendering the choice as a suggested action or hero card. + This is optional. + + :return: The action to use when rendering the choice as a suggested action or hero card. + :rtype: CardAction + """ + return self._action + + @action.setter + def action(self, value: CardAction) -> None: + """Sets the action to use when rendering the choice as a suggested action or hero card. + This is optional. + + :param value: The action to use when rendering the choice as a suggested action or hero card. + :type value: CardAction + :return: + :rtype: None + """ + self._action = value + + @property + def synonyms(self) -> List[str]: + """Gets the list of synonyms to recognize in addition to the value. This is optional. + + :return: The list of synonyms to recognize in addition to the value. + :rtype: List[str] + """ + return self._synonyms + + @synonyms.setter + def synonyms(self, value: List[str]) -> None: + """Sets the list of synonyms to recognize in addition to the value. This is optional. + + :param value: The list of synonyms to recognize in addition to the value. + :type value: List[str] + :return: + :rtype: None + """ + self._synonyms = value diff --git a/libraries/botbuilder-dialogs/tests/choices/test_choice.py b/libraries/botbuilder-dialogs/tests/choices/test_choice.py new file mode 100644 index 000000000..2bf7bc447 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/choices/test_choice.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest +from typing import List + +from botbuilder.dialogs.choices import Choice +from botbuilder.schema import CardAction + + +class ChoiceTest(unittest.TestCase): + def test_value_round_trips(self) -> None: + choice = Choice() + expected = "any" + choice.value = expected + self.assertIs(expected, choice.value) + + def test_action_round_trips(self) -> None: + choice = Choice() + expected = CardAction() + choice.action = expected + self.assertIs(expected, choice.action) + + def test_synonyms_round_trips(self) -> None: + choice = Choice() + expected: List[str] = [] + choice.synonyms = expected + self.assertIs(expected, choice.synonyms) From 4e732cc0af575eef260631ea20b8907b66e2520e Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Thu, 18 Apr 2019 10:53:16 -0700 Subject: [PATCH 18/73] Confirm prompt safe keeping --- .../dialogs/prompts/confirm_prompt.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py index e69de29bb..3d6a715bc 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from botbuilder.core.turn_context import TurnContext +from botbuilder.schema.connector_client_enums import ActivityTypes + + + +class ConfirmPrompt(Prompt): + # TODO: Fix to reference recognizer to use proper constants + choice_defaults : Dict[string, object] = { + { 'English', (Choice("Si"), Choice("No"), ChoiceFactoryOptions(", ", " o ", ", o ", True)) }, + { 'Dutch', (Choice("Ja"), Choice("Nee"), ChoiceFactoryOptions(", ", " of ", ", of ", True)) }, + { 'English', (Choice("Yes"), Choice("No"), ChoiceFactoryOptions(", ", " or ", ", or ", True)) }, + { 'French', (Choice("Oui"), Choice("Non"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)) }, + { 'German', (Choice("Ja"), Choice("Nein"), ChoiceFactoryOptions(", ", " oder ", ", oder ", True)) }, + { 'Japanese', (Choice("はい"), Choice("いいえ"), ChoiceFactoryOptions("、 ", " または ", "、 または ", True)) }, + { 'Portuguese', (Choice("Sim"), Choice("Não"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)) }, + { 'Chinese', (Choice("是的"), Choice("不"), ChoiceFactoryOptions(", ", " 要么 ", ", 要么 ", True)) }, + } + + # TODO: PromptValidator + def __init__(self, dialog_id: str, validator: object, default_locale: str): + super(ConfirmPrompt, self).__init__(dialog_id, validator) + if dialogs is None: + raise TypeError('ConfirmPrompt(): dialogs cannot be None.') + self.style = ListStyle.auto + self.default_locale = defaultLocale + self.choice_options = None + self.confirm_choices = None + + async def on_prompt(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions, is_retry: bool): + if not turn_context: + raise TypeError('ConfirmPrompt.on_prompt(): turn_context cannot be None.') + if not options: + raise TypeError('ConfirmPrompt.on_prompt(): options cannot be None.') + + # Format prompt to send + channel_id = turn_context.activity.channel_id + culture = determine_culture(turn_context.activity) + defaults = choice_defaults[culture] + choice_opts = choice_options if choice_options != None else defaults[2] + confirms = confirm_choices if confirm_choices != None else (defaults[0], defaults[1]) + choices = { confirms[0], confirms[1] } + if is_retry == True and options.retry_prompt != None: + prompt = append_choices(options.retry_prompt) + else: + prompt = append_choices(options.prompt, channel_id, choices, self.style, choice_opts) + turn_context.send_activity(prompt) + + async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions) -> PromptRecognizerResult: + if not turn_context: + raise TypeError('ConfirmPrompt.on_prompt(): turn_context cannot be None.') + + result = PromptRecognizerResult(); + if turn_context.activity.type == ActivityTypes.message: + # Recognize utterance + message = turn_context.activity + culture = determine_culture(turn_context.activity) + results = ChoiceRecognizer.recognize_boolean(message.text, culture) + if results.Count > 0: + first = results[0]; + if "value" in first.Resolution: + result.Succeeded = true; + result.Value = first.Resolution["value"].str; + else: + # First check whether the prompt was sent to the user with numbers - if it was we should recognize numbers + defaults = choice_defaults[culture]; + opts = choice_options if choice_options != None else defaults[2] + + # This logic reflects the fact that IncludeNumbers is nullable and True is the default set in Inline style + if opts.include_numbers.has_value or opts.include_numbers.value: + # The text may be a number in which case we will interpret that as a choice. + confirmChoices = confirm_choices if confirm_choices != None else (defaults[0], defaults[1]) + choices = { confirmChoices[0], confirmChoices[1] }; + secondAttemptResults = ChoiceRecognizers.recognize_choices(message.text, choices); + if len(secondAttemptResults) > 0: + result.succeeded = True + result.value = secondAttemptResults[0].resolution.index == 0; + + return result; + + def determine_culture(self, activity: Activity) -> str: + culture = activity.locale if activity.locale != None else default_locale + if not culture or not culture in choice_defaults: + culture = "English" # TODO: Fix to reference recognizer to use proper constants + return culture \ No newline at end of file From e40da60e72f517787d105d2cbefdb3893901bc1a Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Thu, 18 Apr 2019 13:04:42 -0700 Subject: [PATCH 19/73] Initial DateTimePrompt --- .../dialogs/prompts/confirm_prompt.py | 49 ++++++------- .../dialogs/prompts/datetime_prompt.py | 72 +++++++++++++++++++ .../dialogs/prompts/datetime_resolution.py | 10 +++ 3 files changed, 107 insertions(+), 24 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py index 3d6a715bc..717472aee 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from botbuilder.core.turn_context import TurnContext from botbuilder.schema.connector_client_enums import ActivityTypes @@ -51,31 +52,31 @@ async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object] if not turn_context: raise TypeError('ConfirmPrompt.on_prompt(): turn_context cannot be None.') - result = PromptRecognizerResult(); - if turn_context.activity.type == ActivityTypes.message: - # Recognize utterance - message = turn_context.activity - culture = determine_culture(turn_context.activity) - results = ChoiceRecognizer.recognize_boolean(message.text, culture) - if results.Count > 0: - first = results[0]; - if "value" in first.Resolution: - result.Succeeded = true; - result.Value = first.Resolution["value"].str; - else: - # First check whether the prompt was sent to the user with numbers - if it was we should recognize numbers - defaults = choice_defaults[culture]; - opts = choice_options if choice_options != None else defaults[2] + result = PromptRecognizerResult(); + if turn_context.activity.type == ActivityTypes.message: + # Recognize utterance + message = turn_context.activity + culture = determine_culture(turn_context.activity) + results = ChoiceRecognizer.recognize_boolean(message.text, culture) + if results.Count > 0: + first = results[0]; + if "value" in first.Resolution: + result.Succeeded = true; + result.Value = first.Resolution["value"].str; + else: + # First check whether the prompt was sent to the user with numbers - if it was we should recognize numbers + defaults = choice_defaults[culture]; + opts = choice_options if choice_options != None else defaults[2] - # This logic reflects the fact that IncludeNumbers is nullable and True is the default set in Inline style - if opts.include_numbers.has_value or opts.include_numbers.value: - # The text may be a number in which case we will interpret that as a choice. - confirmChoices = confirm_choices if confirm_choices != None else (defaults[0], defaults[1]) - choices = { confirmChoices[0], confirmChoices[1] }; - secondAttemptResults = ChoiceRecognizers.recognize_choices(message.text, choices); - if len(secondAttemptResults) > 0: - result.succeeded = True - result.value = secondAttemptResults[0].resolution.index == 0; + # This logic reflects the fact that IncludeNumbers is nullable and True is the default set in Inline style + if opts.include_numbers.has_value or opts.include_numbers.value: + # The text may be a number in which case we will interpret that as a choice. + confirmChoices = confirm_choices if confirm_choices != None else (defaults[0], defaults[1]) + choices = { confirmChoices[0], confirmChoices[1] }; + secondAttemptResults = ChoiceRecognizers.recognize_choices(message.text, choices); + if len(secondAttemptResults) > 0: + result.succeeded = True + result.value = secondAttemptResults[0].resolution.index == 0; return result; diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py index e69de29bb..a911e15ea 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict +from botbuilder.schema.connector_client_enums import ActivityTypes +from .date_time_resolution import DateTimeResolution + +class DateTimePrompt(Prompt): + def __init__(self, dialog_id: str, validator: PromptValidator = None, default_locale: str = None): + super(DateTimePrompt, self).__init__(dialog_id, validator) + self._default_locale = default_locale; + + @property + def default_locale(self) -> str: + """Gets the locale used if `TurnContext.activity.locale` is not specified. + """ + return self._default_locale + + @id.setter + def default_locale(self, value: str) -> None: + """Gets the locale used if `TurnContext.activity.locale` is not specified. + + :param value: The locale used if `TurnContext.activity.locale` is not specified. + """ + self._default_locale = value + + async def on_prompt(self, turn_context: TurnContext, state: Dict[string, object], options: PromptOptions, is_retry: bool): + if not turn_context: + raise TypeError('DateTimePrompt.on_prompt(): turn_context cannot be None.') + if not options: + raise TypeError('DateTimePrompt.on_prompt(): options cannot be None.') + + if is_retry == True and options.retry_prompt != None: + prompt = turn_context.send_activity(options.retry_prompt) + else: + if options.prompt != None: + turn_context.send_activity(options.prompt) + + async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions) -> PromptRecognizerResult: + if not turn_context: + raise TypeError('DateTimePrompt.on_recognize(): turn_context cannot be None.') + + result = PromptRecognizerResult() + if turn_context.activity.type == ActivityTypes.message: + # Recognize utterance + message = turn_context.activity + culture = determine_culture(turn_context.activity) + results = ChoiceRecognizer.recognize_boolean(message.text, culture) + if results.Count > 0: + result.succeeded = True; + result.value = [] + values = results[0] + for value in values: + result.value.append(read_resolution(value)) + + return result; + + def read_resolution(self, resolution: Dict[str, str]) -> DateTimeResolution: + result = DateTimeResolution() + + if "timex" in resolution: + result.timex = resolution["timex"] + if "value" in resolution: + result.value = resolution["value"] + if "start" in resolution: + result.start= resolution["start"] + if "end" in resolution: + result.end = resolution["end"] + + return result + + diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py index e69de29bb..3cd022eb6 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +class DateTimeResolution: + def __init__(self): + self.value = None + self.start = None + self.end = None + self.timex = None + From 6a8597883c501eab9d8796c011269dfff388eaad Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Thu, 18 Apr 2019 14:41:17 -0700 Subject: [PATCH 20/73] First Number Prompt test, fix a bunch of issues --- .../botbuilder/dialogs/prompts/__init__.py | 20 ++++++++- .../dialogs/prompts/confirm_prompt.py | 32 ++++++++++----- .../dialogs/prompts/datetime_prompt.py | 14 ++++--- .../botbuilder/dialogs/prompts/prompt.py | 23 ++++++----- .../dialogs/prompts/prompt_options.py | 33 +++++++-------- .../prompts/prompt_recognizer_result.py | 10 ++--- .../prompts/prompt_validator_context.py | 2 +- .../botbuilder/dialogs/prompts/text_prompt.py | 41 +++++++++++++++++++ .../tests/test_number_prompt.py | 10 +++++ 9 files changed, 135 insertions(+), 50 deletions(-) create mode 100644 libraries/botbuilder-dialogs/tests/test_number_prompt.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py index 5422a0458..b25c0af05 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py @@ -5,8 +5,24 @@ # license information. # -------------------------------------------------------------------------- +from .confirm_prompt import ConfirmPrompt +from .datetime_prompt import DateTimePrompt +from .datetime_resolution import DateTimeResolution +from .number_prompt import NumberPrompt +from .prompt_options import PromptOptions +from .prompt_recognizer_result import PromptRecognizerResult +from .prompt_validator_context import PromptValidatorContext from .prompt import Prompt from .prompt_options import PromptOptions +from .text_prompt import TextPrompt -__all__ = ["Prompt", - "PromptOptions"] \ No newline at end of file +__all__ = ["ConfirmPrompt", + "DateTimePrompt", + "DateTimeResolution", + "NumbersPrompt", + "PromptOptions", + "PromptRecognizerResult", + "PromptValidatorContext", + "Prompt", + "PromptOptions", + "TextPrompt"] \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py index 717472aee..d592c8bab 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py @@ -1,22 +1,32 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import Dict from botbuilder.core.turn_context import TurnContext -from botbuilder.schema.connector_client_enums import ActivityTypes +from botbuilder.schema import (ActivityTypes, Activity) +from botbuilder.dialogs.choices import Choice +from botbuilder.dialogs.choices import ChoiceFactoryOptions +from .prompt import Prompt +from .prompt_options import PromptOptions +from .prompt_recognizer_result import PromptRecognizerResult - +class choice_default: + def __init__(self, affirm: Choice, negate: Choice, opts: ChoiceFactoryOptions): + self.affirm = affirm + self.negate = negate + self.opts = opts class ConfirmPrompt(Prompt): # TODO: Fix to reference recognizer to use proper constants - choice_defaults : Dict[string, object] = { - { 'English', (Choice("Si"), Choice("No"), ChoiceFactoryOptions(", ", " o ", ", o ", True)) }, - { 'Dutch', (Choice("Ja"), Choice("Nee"), ChoiceFactoryOptions(", ", " of ", ", of ", True)) }, - { 'English', (Choice("Yes"), Choice("No"), ChoiceFactoryOptions(", ", " or ", ", or ", True)) }, - { 'French', (Choice("Oui"), Choice("Non"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)) }, - { 'German', (Choice("Ja"), Choice("Nein"), ChoiceFactoryOptions(", ", " oder ", ", oder ", True)) }, - { 'Japanese', (Choice("はい"), Choice("いいえ"), ChoiceFactoryOptions("、 ", " または ", "、 または ", True)) }, - { 'Portuguese', (Choice("Sim"), Choice("Não"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)) }, - { 'Chinese', (Choice("是的"), Choice("不"), ChoiceFactoryOptions(", ", " 要么 ", ", 要么 ", True)) }, + choice_defaults : Dict[str, object] = { + 'English': choice_default(Choice("Si"), Choice("No"), ChoiceFactoryOptions(", ", " o ", ", o ", True)), + 'Dutch': choice_default(Choice("Ja"), Choice("Nee"), ChoiceFactoryOptions(", ", " of ", ", of ", True)), + 'English': choice_default(Choice("Yes"), Choice("No"), ChoiceFactoryOptions(", ", " or ", ", or ", True)), + 'French': choice_default(Choice("Oui"), Choice("Non"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)), + 'German': choice_default(Choice("Ja"), Choice("Nein"), ChoiceFactoryOptions(", ", " oder ", ", oder ", True)), + 'Japanese': choice_default(Choice("はい"), Choice("いいえ"), ChoiceFactoryOptions("、 ", " または ", "、 または ", True)), + 'Portuguese': choice_default(Choice("Sim"), Choice("Não"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)), + 'Chinese': choice_default(Choice("是的"), Choice("不"), ChoiceFactoryOptions(", ", " 要么 ", ", 要么 ", True)) } # TODO: PromptValidator diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py index a911e15ea..95f29ddef 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py @@ -2,11 +2,15 @@ # Licensed under the MIT License. from typing import Dict -from botbuilder.schema.connector_client_enums import ActivityTypes -from .date_time_resolution import DateTimeResolution +from botbuilder.core.turn_context import TurnContext +from botbuilder.schema import (ActivityTypes, Activity) +from .datetime_resolution import DateTimeResolution +from .prompt import Prompt +from .prompt_options import PromptOptions +from .prompt_recognizer_result import PromptRecognizerResult class DateTimePrompt(Prompt): - def __init__(self, dialog_id: str, validator: PromptValidator = None, default_locale: str = None): + def __init__(self, dialog_id: str, validator: object = None, default_locale: str = None): super(DateTimePrompt, self).__init__(dialog_id, validator) self._default_locale = default_locale; @@ -16,7 +20,7 @@ def default_locale(self) -> str: """ return self._default_locale - @id.setter + @default_locale.setter def default_locale(self, value: str) -> None: """Gets the locale used if `TurnContext.activity.locale` is not specified. @@ -24,7 +28,7 @@ def default_locale(self, value: str) -> None: """ self._default_locale = value - async def on_prompt(self, turn_context: TurnContext, state: Dict[string, object], options: PromptOptions, is_retry: bool): + async def on_prompt(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions, is_retry: bool): if not turn_context: raise TypeError('DateTimePrompt.on_prompt(): turn_context cannot be None.') if not options: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index 6ae21bdb1..dca7c3dfa 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -1,22 +1,25 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.dialogs.dialog_turn_result import DialogTurnResult -from botbuilder.dialogs.dialog_context import DialogContext +from typing import Dict from .prompt_options import PromptOptions -from botbuilder.schema.connector_client_enums import InputHints, ActivityTypes -from botbuilder.dialogs.dialog_reason import DialogReason +from ..dialog_reason import DialogReason +from ..dialog import Dialog +from ..dialog_instance import DialogInstance +from ..dialog_turn_result import DialogTurnResult +from ..dialog_context import DialogContext from botbuilder.core.turn_context import TurnContext -from botbuilder.dialogs.dialog_instance import DialogInstance +from botbuilder.schema import InputHints, ActivityTypes + from abc import abstractmethod -from botbuilder.schema.activity import Activity +from botbuilder.schema import Activity """ Base class for all prompts. """ class Prompt(Dialog): persisted_options = "options"; persisted_state = "state"; - def __init__(self, dialog_id: str, validator: Object = None): + def __init__(self, dialog_id: str, validator: object = None): """Creates a new Prompt instance. Parameters ---------- @@ -31,7 +34,7 @@ def __init__(self, dialog_id: str, validator: Object = None): self._validator = validator; - async def begin_dialog(self, dc: DialogContext, options: Object) -> DialogTurnResult: + async def begin_dialog(self, dc: DialogContext, options: object) -> DialogTurnResult: if not dc: raise TypeError('Prompt(): dc cannot be None.') if not options is PromptOptions: @@ -46,7 +49,7 @@ async def begin_dialog(self, dc: DialogContext, options: Object) -> DialogTurnRe # Initialize prompt state state = dc.active_dialog.state; state[persisted_options] = options; - state[persisted_state] = Dict[str, Object] + state[persisted_state] = Dict[str, object] # Send initial prompt await on_prompt(dc.context, state[persisted_state], state[persisted_options], False) @@ -84,7 +87,7 @@ async def continue_dialog(self, dc: DialogContext): await on_prompt(dc.context, state, options, true) return Dialog.end_of_turn; - async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: Object) -> DialogTurnResult: + async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: object) -> DialogTurnResult: # Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs # on top of the stack which will result in the prompt receiving an unexpected call to # dialog_resume() when the pushed on dialog ends. diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py index 7007b60d5..4ebd911e7 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py @@ -1,17 +1,19 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.schema.activity import Activity +from botbuilder.schema import Activity + class PromptOptions: + def __init__(self): self._prompt: Activity = None self._retry_prompt: Activity = None # TODO: Replace with Choice Object once ported self._choices: [] = None # TODO: Replace with ListStyle Object once ported - self._style: Object = None - self._validations: Object = None + self._style: object = None + self._validations: object = None self._number_of_attempts: int = 0 @property @@ -20,7 +22,7 @@ def prompt(self) -> Activity: """ return self._prompt - @id.setter + @prompt.setter def prompt(self, value: Activity) -> None: """Sets the initial prompt to send the user as Activity. Parameters @@ -36,7 +38,7 @@ def retry_prompt(self) -> Activity: """ return self._retry_prompt - @id.setter + @retry_prompt.setter def retry_prompt(self, value: Activity) -> None: """Sets the retry prompt to send the user as Activity. Parameters @@ -47,13 +49,13 @@ def retry_prompt(self, value: Activity) -> None: self._retry_prompt = value @property - def choices(self) -> Object: + def choices(self) -> object: """Gets the list of choices associated with the prompt. """ return self._choices - @id.setter - def choices(self, value: Object) -> None: + @choices.setter + def choices(self, value: object) -> None: """Sets the list of choices associated with the prompt. Parameters ---------- @@ -63,13 +65,13 @@ def choices(self, value: Object) -> None: self._choices = value @property - def style(self) -> Object: + def style(self) -> object: """Gets the ListStyle for a ChoicePrompt. """ return self._style - @id.setter - def style(self, value: Object) -> None: + @style.setter + def style(self, value: object) -> None: """Sets the ListStyle for a ChoicePrompt. Parameters ---------- @@ -79,13 +81,13 @@ def style(self, value: Object) -> None: self._style = value @property - def validations(self) -> Object: + def validations(self) -> object: """Gets additional validation rules to pass the prompts validator routine. """ return self._validations - @id.setter - def validations(self, value: Object) -> None: + @validations.setter + def validations(self, value: object) -> None: """Sets additional validation rules to pass the prompts validator routine. Parameters ---------- @@ -100,7 +102,7 @@ def number_of_attempts(self) -> int: """ return self._number_of_attempts - @id.setter + @number_of_attempts.setter def number_of_attempts(self, value: int) -> None: """Sets the count of the number of times the prompt has retried. Parameters @@ -109,5 +111,4 @@ def number_of_attempts(self, value: int) -> None: Count of the number of times the prompt has retried. """ self._number_of_attempts = value - diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py index 0dd919594..432de64e5 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_recognizer_result.py @@ -8,7 +8,7 @@ def __init__(self): """Creates result returned by a prompts recognizer function. """ self._succeeded : bool = False - self._value : Object = None + self._value : object = None @property def succeeded(self) -> bool: @@ -16,7 +16,7 @@ def succeeded(self) -> bool: """ return self._succeeded - @id.setter + @succeeded.setter def succeeded(self, value: bool) -> None: """Sets the whether the users utterance was successfully recognized Parameters @@ -27,13 +27,13 @@ def succeeded(self, value: bool) -> None: self._succeeded = value @property - def value(self) -> Object: + def value(self) -> object: """Gets the value that was recognized if succeeded is `True` """ return self._value - @id.setter - def value(self, value: Object) -> None: + @value.setter + def value(self, value: object) -> None: """Sets the value that was recognized (if succeeded is `True`) Parameters ---------- diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py index 36b62aa93..c2220aa33 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py @@ -9,7 +9,7 @@ """ Contextual information passed to a custom `PromptValidator`. """ class PromptValidatorContext(): - def __init__(self, turn_context: TurnContext, recognized: PromptRecognizerResult, state: Dict[str, Object], options: PromptOptions): + def __init__(self, turn_context: TurnContext, recognized: PromptRecognizerResult, state: Dict[str, object], options: PromptOptions): """Creates contextual information passed to a custom `PromptValidator`. Parameters ---------- diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py index e69de29bb..216d366fb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict +from botbuilder.core.turn_context import TurnContext +from botbuilder.schema import (ActivityTypes, Activity) +from .datetime_resolution import DateTimeResolution +from .prompt import Prompt +from .prompt_options import PromptOptions +from .prompt_recognizer_result import PromptRecognizerResult + + +class TextPrompt(Prompt): + # TODO: PromptValidator + def __init__(self, dialog_id: str, validator: object): + super(ConfirmPrompt, self).__init__(dialog_id, validator) + + async def on_prompt(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions, is_retry: bool): + if not turn_context: + raise TypeError('TextPrompt.on_prompt(): turn_context cannot be None.') + if not options: + raise TypeError('TextPrompt.on_prompt(): options cannot be None.') + + if is_retry == True and options.retry_prompt != None: + prompt = turn_context.send_activity(options.retry_prompt) + else: + if options.prompt != None: + turn_context.send_activity(options.prompt) + + + async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions) -> PromptRecognizerResult: + if not turn_context: + raise TypeError('DateTimePrompt.on_recognize(): turn_context cannot be None.') + + result = PromptRecognizerResult() + if turn_context.activity.type == ActivityTypes.message: + message = turn_context.activity + if message.text != None: + result.succeeded = True + result.value = message.text + return result; diff --git a/libraries/botbuilder-dialogs/tests/test_number_prompt.py b/libraries/botbuilder-dialogs/tests/test_number_prompt.py new file mode 100644 index 000000000..5428893fd --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/test_number_prompt.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import aiounittest +from botbuilder.dialogs.prompts import NumberPrompt + +class NumberPromptTests(aiounittest.AsyncTestCase): + def test_empty_should_fail(self): + empty_id = '' + self.assertRaises(TypeError, lambda:NumberPrompt(empty_id)) + From 798b6164fe35bda743998e89daf179ce96f3f5d4 Mon Sep 17 00:00:00 2001 From: congysu Date: Thu, 18 Apr 2019 10:15:45 -0700 Subject: [PATCH 21/73] Add ChoiceFactory --- .../botbuilder/dialogs/choices/__init__.py | 3 +- .../dialogs/choices/choice_factory.py | 197 ++++++++++++++++++ .../tests/choices/test_choice_factory.py | 21 ++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 libraries/botbuilder-dialogs/tests/choices/test_choice_factory.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py index 90589c39c..5817242e6 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/__init__.py @@ -8,5 +8,6 @@ from .channel import Channel from .choice import Choice from .choice_factory_options import ChoiceFactoryOptions +from .choice_factory import ChoiceFactory -__all__ = ["Channel", "Choice", "ChoiceFactoryOptions"] +__all__ = ["Channel", "Choice", "ChoiceFactory", "ChoiceFactoryOptions"] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py index e69de29bb..43f291196 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py @@ -0,0 +1,197 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.core import CardFactory, MessageFactory +from botbuilder.schema import ActionTypes, Activity, CardAction, HeroCard, InputHints + +from . import Channel, Choice, ChoiceFactoryOptions + + +class ChoiceFactory: + @staticmethod + def for_channel( + channelId: str, + choices: List[Choice], + text: str = None, + speak: str = None, + options: ChoiceFactoryOptions = None, + ) -> Activity: + if channelId is None: + channelId = "" + + if choices is None: + choices = [] + + # Find maximum title length + maxTitleLength = 0 + for choice in choices: + if choice.action is not None and choice.action.title not in (None, ""): + l = len(choice.action.title) + else: + l = len(choice.value) + + if l > maxTitleLength: + maxTitleLength = l + + # Determine list style + supportsSuggestedActions = Channel.supports_suggested_actions( + channelId, len(choices) + ) + supportsCardActions = Channel.supports_card_actions(channelId, len(choices)) + maxActionTitleLength = Channel.max_action_title_length(channelId) + longTitles = maxTitleLength > maxActionTitleLength + + if not longTitles and not supportsSuggestedActions and supportsCardActions: + # SuggestedActions is the preferred approach, but for channels that don't + # support them (e.g. Teams, Cortana) we should use a HeroCard with CardActions + return HeroCard(choices, text, speak) + elif not longTitles and supportsSuggestedActions: + # We always prefer showing choices using suggested actions. If the titles are too long, however, + # we'll have to show them as a text list. + return ChoiceFactory.suggested_action(choices, text, speak) + elif not longTitles and len(choices) <= 3: + # If the titles are short and there are 3 or less choices we'll use an inline list. + return ChoiceFactory.inline(choices, text, speak, options) + else: + # Show a numbered list. + return List(choices, text, speak, options) + + @staticmethod + def inline( + choices: List[Choice], + text: str = None, + speak: str = None, + options: ChoiceFactoryOptions = None, + ) -> Activity: + if choices is None: + choices = [] + + if options is None: + options = ChoiceFactoryOptions() + + opt = ChoiceFactoryOptions( + inline_separator=options.inline_separator or ", ", + inline_or=options.inline_or or " or ", + inline_or_more=options.inline_or_more or ", or ", + include_numbers=options.include_numbers or True, + ) + + # Format list of choices + connector = "" + txtBuilder: List[str] = [text] + txtBuilder.append(" ") + for index, choice in enumerate(choices): + title = ( + choice.action.title + if (choice.action is not None and choice.action.title is not None) + else choice.value + ) + txtBuilder.append(connector) + if opt.include_numbers is True: + txtBuilder.append("(") + txtBuilder.append(f"{index + 1}") + txtBuilder.append(") ") + + txtBuilder.append(title) + if index == (len(choices) - 2): + connector = opt.inline_or if index == 0 else opt.inline_or_more + connector = connector or "" + else: + connector = opt.inline_separator or "" + + # Return activity with choices as an inline list. + return MessageFactory.text( + "".join(txtBuilder), speak, InputHints.expecting_input + ) + + @staticmethod + def list( + choices: List[Choice], + text: str = None, + speak: str = None, + options: ChoiceFactoryOptions = None, + ): + if choices is None: + choices = [] + options = options or ChoiceFactoryOptions() + + includeNumbers = options.IncludeNumbers or True + + # Format list of choices + connector = "" + txtBuilder = [text] + txtBuilder.append("\n\n ") + + for index, choice in enumerate(choices): + title = ( + choice.Action.Title + if choice.Action is not None and choice.Action.Title is not None + else choice.Value + ) + + txtBuilder.append(connector) + if includeNumbers: + txtBuilder.append(index + 1) + txtBuilder.append(". ") + else: + txtBuilder.append("- ") + + txtBuilder.append(title) + connector = "\n " + + # Return activity with choices as a numbered list. + txt = "".join(txtBuilder) + return MessageFactory.text(txt, speak, InputHints.expecting_input) + + @staticmethod + def suggested_action( + choices: List[Choice], text: str = None, speak: str = None + ) -> Activity: + # Return activity with choices as suggested actions + return MessageFactory.suggested_actions( + ChoiceFactory._extract_actions(choices), + text, + speak, + InputHints.expecting_input, + ) + + @staticmethod + def hero_card( + choices: List[Choice], text: str = None, speak: str = None + ) -> Activity: + attachments = [ + CardFactory.hero_card( + HeroCard(text=text, buttons=ChoiceFactory._extract_actions(choices)) + ) + ] + + # Return activity with choices as HeroCard with buttons + return MessageFactory.attachment( + attachments, None, speak, InputHints.expecting_input + ) + + @staticmethod + def _to_choices(choices: List[str]) -> List[Choice]: + if choices is None: + return [] + else: + return [Choice(value=choice.value) for choice in choices] + + @staticmethod + def _extract_actions(choices: List[Choice]) -> List[CardAction]: + if choices is None: + choices = [] + card_actions: List[CardAction] = [] + for choice in choices: + if choice.Action is not None: + card_action = choice.Action + else: + card_action = CardAction( + type=ActionTypes.im_back, value=choice.Value, title=choice.Value + ) + + card_actions.append(card_action) + + return card_actions diff --git a/libraries/botbuilder-dialogs/tests/choices/test_choice_factory.py b/libraries/botbuilder-dialogs/tests/choices/test_choice_factory.py new file mode 100644 index 000000000..f249c3772 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/choices/test_choice_factory.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest +from typing import List + +from botbuilder.core import CardFactory, MessageFactory +from botbuilder.dialogs.choices import ( + ChoiceFactory, + Choice, + ChoiceFactoryOptions +) +from botbuilder.schema import ActionTypes, Activity, CardAction, HeroCard, InputHints + + +class ChoiceFactoryTest(unittest.TestCase): + color_choices = [Choice("red"), Choice("green"), Choice("blue")] + + def test_inline_should_render_choices_inline(self): + activity = ChoiceFactory.inline(ChoiceFactoryTest.color_choices, "select from:") + self.assertEqual("select from: (1) red, (2) green, or (3) blue", activity.text) From c522b72608f08317964d1655c05a170b4e08cf4c Mon Sep 17 00:00:00 2001 From: congysu Date: Fri, 19 Apr 2019 13:38:47 -0700 Subject: [PATCH 22/73] add unittest for choice factory * update ChoiceFactory etc. * update casing for Channels, and others. --- .../botbuilder/dialogs/choices/channel.py | 42 +-- .../botbuilder/dialogs/choices/choice.py | 10 +- .../dialogs/choices/choice_factory.py | 94 +++---- .../tests/choices/test_channel.py | 2 +- .../tests/choices/test_choice_factory.py | 257 +++++++++++++++++- .../botframework/connector/channels.py | 35 +-- 6 files changed, 345 insertions(+), 95 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py index 999d1c42d..6f23f5dd1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/channel.py @@ -24,17 +24,17 @@ def supports_suggested_actions(channel_id: str, button_cnt: int = 100) -> bool: max_actions = { # https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies - Channels.Facebook: 10, - Channels.Skype: 10, + Channels.facebook: 10, + Channels.skype: 10, # https://developers.line.biz/en/reference/messaging-api/#items-object - Channels.Line: 13, + Channels.line: 13, # https://dev.kik.com/#/docs/messaging#text-response-object - Channels.Kik: 20, - Channels.Telegram: 100, - Channels.Slack: 100, - Channels.Emulator: 100, - Channels.Directline: 100, - Channels.Webchat: 100, + Channels.kik: 20, + Channels.telegram: 100, + Channels.slack: 100, + Channels.emulator: 100, + Channels.direct_line: 100, + Channels.webchat: 100, } return button_cnt <= max_actions[channel_id] if channel_id in max_actions else False @@ -51,15 +51,15 @@ def supports_card_actions(channel_id: str, button_cnt: int = 100) -> bool: """ max_actions = { - Channels.Facebook: 3, - Channels.Skype: 3, - Channels.Msteams: 3, - Channels.Line: 99, - Channels.Slack: 100, - Channels.Emulator: 100, - Channels.Directline: 100, - Channels.Webchat: 100, - Channels.Cortana: 100, + Channels.facebook: 3, + Channels.skype: 3, + Channels.ms_teams: 3, + Channels.line: 99, + Channels.slack: 100, + Channels.emulator: 100, + Channels.direct_line: 100, + Channels.webchat: 100, + Channels.cortana: 100, } return button_cnt <= max_actions[channel_id] if channel_id in max_actions else False @@ -74,7 +74,7 @@ def has_message_feed(channel_id: str) -> bool: bool: True if the Channel has a Message Feed, False if it does not. """ - return False if channel_id == Channels.Cortana else True + return False if channel_id == Channels.cortana else True @staticmethod def max_action_title_length(channel_id: str) -> int: @@ -100,7 +100,7 @@ def get_channel_id(turn_context: TurnContext) -> str: str: The Channel Id from the Turn Context's Activity. """ - if turn_context.activity.channelId is None: + if turn_context.activity.channel_id is None: return "" else: - return turn_context.activity.channelId + return turn_context.activity.channel_id diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py index 64317e6bb..663f8b43e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice.py @@ -7,10 +7,12 @@ class Choice(object): - def __init__(self, value: str = None): - self._value = value - self._action = None - self._synonyms = None + def __init__( + self, value: str = None, action: CardAction = None, synonyms: List[str] = None + ): + self._value: str = value + self._action: CardAction = action + self._synonyms: List[str] = synonyms @property def value(self) -> str: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py index 43f291196..9b29f77d4 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py @@ -12,46 +12,46 @@ class ChoiceFactory: @staticmethod def for_channel( - channelId: str, + channel_id: str, choices: List[Choice], text: str = None, speak: str = None, options: ChoiceFactoryOptions = None, ) -> Activity: - if channelId is None: - channelId = "" + if channel_id is None: + channel_id = "" if choices is None: choices = [] # Find maximum title length - maxTitleLength = 0 + max_title_length = 0 for choice in choices: if choice.action is not None and choice.action.title not in (None, ""): l = len(choice.action.title) else: l = len(choice.value) - if l > maxTitleLength: - maxTitleLength = l + if l > max_title_length: + max_title_length = l # Determine list style - supportsSuggestedActions = Channel.supports_suggested_actions( - channelId, len(choices) + supports_suggested_actions = Channel.supports_suggested_actions( + channel_id, len(choices) ) - supportsCardActions = Channel.supports_card_actions(channelId, len(choices)) - maxActionTitleLength = Channel.max_action_title_length(channelId) - longTitles = maxTitleLength > maxActionTitleLength + supports_card_actions = Channel.supports_card_actions(channel_id, len(choices)) + max_action_title_length = Channel.max_action_title_length(channel_id) + long_titles = max_title_length > max_action_title_length - if not longTitles and not supportsSuggestedActions and supportsCardActions: + if not long_titles and not supports_suggested_actions and supports_card_actions: # SuggestedActions is the preferred approach, but for channels that don't # support them (e.g. Teams, Cortana) we should use a HeroCard with CardActions - return HeroCard(choices, text, speak) - elif not longTitles and supportsSuggestedActions: + return ChoiceFactory.hero_card(choices, text, speak) + elif not long_titles and supports_suggested_actions: # We always prefer showing choices using suggested actions. If the titles are too long, however, # we'll have to show them as a text list. return ChoiceFactory.suggested_action(choices, text, speak) - elif not longTitles and len(choices) <= 3: + elif not long_titles and len(choices) <= 3: # If the titles are short and there are 3 or less choices we'll use an inline list. return ChoiceFactory.inline(choices, text, speak, options) else: @@ -80,21 +80,21 @@ def inline( # Format list of choices connector = "" - txtBuilder: List[str] = [text] - txtBuilder.append(" ") + txt_builder: List[str] = [text] + txt_builder.append(" ") for index, choice in enumerate(choices): title = ( choice.action.title if (choice.action is not None and choice.action.title is not None) else choice.value ) - txtBuilder.append(connector) + txt_builder.append(connector) if opt.include_numbers is True: - txtBuilder.append("(") - txtBuilder.append(f"{index + 1}") - txtBuilder.append(") ") + txt_builder.append("(") + txt_builder.append(f"{index + 1}") + txt_builder.append(") ") - txtBuilder.append(title) + txt_builder.append(title) if index == (len(choices) - 2): connector = opt.inline_or if index == 0 else opt.inline_or_more connector = connector or "" @@ -103,7 +103,7 @@ def inline( # Return activity with choices as an inline list. return MessageFactory.text( - "".join(txtBuilder), speak, InputHints.expecting_input + "".join(txt_builder), speak, InputHints.expecting_input ) @staticmethod @@ -115,34 +115,38 @@ def list( ): if choices is None: choices = [] - options = options or ChoiceFactoryOptions() + if options is None: + options = ChoiceFactoryOptions() - includeNumbers = options.IncludeNumbers or True + if options.include_numbers is None: + include_numbers = True + else: + include_numbers = options.include_numbers # Format list of choices connector = "" - txtBuilder = [text] - txtBuilder.append("\n\n ") + txt_builder = [text] + txt_builder.append("\n\n ") for index, choice in enumerate(choices): title = ( - choice.Action.Title - if choice.Action is not None and choice.Action.Title is not None - else choice.Value + choice.action.title + if choice.action is not None and choice.action.title is not None + else choice.value ) - txtBuilder.append(connector) - if includeNumbers: - txtBuilder.append(index + 1) - txtBuilder.append(". ") + txt_builder.append(connector) + if include_numbers: + txt_builder.append(f"{index + 1}") + txt_builder.append(". ") else: - txtBuilder.append("- ") + txt_builder.append("- ") - txtBuilder.append(title) + txt_builder.append(title) connector = "\n " # Return activity with choices as a numbered list. - txt = "".join(txtBuilder) + txt = "".join(txt_builder) return MessageFactory.text(txt, speak, InputHints.expecting_input) @staticmethod @@ -161,15 +165,13 @@ def suggested_action( def hero_card( choices: List[Choice], text: str = None, speak: str = None ) -> Activity: - attachments = [ - CardFactory.hero_card( - HeroCard(text=text, buttons=ChoiceFactory._extract_actions(choices)) - ) - ] + attachment = CardFactory.hero_card( + HeroCard(text=text, buttons=ChoiceFactory._extract_actions(choices)) + ) # Return activity with choices as HeroCard with buttons return MessageFactory.attachment( - attachments, None, speak, InputHints.expecting_input + attachment, None, speak, InputHints.expecting_input ) @staticmethod @@ -185,11 +187,11 @@ def _extract_actions(choices: List[Choice]) -> List[CardAction]: choices = [] card_actions: List[CardAction] = [] for choice in choices: - if choice.Action is not None: - card_action = choice.Action + if choice.action is not None: + card_action = choice.action else: card_action = CardAction( - type=ActionTypes.im_back, value=choice.Value, title=choice.Value + type=ActionTypes.im_back, value=choice.value, title=choice.value ) card_actions.append(card_action) diff --git a/libraries/botbuilder-dialogs/tests/choices/test_channel.py b/libraries/botbuilder-dialogs/tests/choices/test_channel.py index 88d7ff37e..f42c4f179 100644 --- a/libraries/botbuilder-dialogs/tests/choices/test_channel.py +++ b/libraries/botbuilder-dialogs/tests/choices/test_channel.py @@ -9,5 +9,5 @@ class ChannelTest(unittest.TestCase): def test_supports_suggested_actions(self): - actual = Channel.supports_suggested_actions(Channels.Facebook, 5) + actual = Channel.supports_suggested_actions(Channels.facebook, 5) self.assertTrue(actual) diff --git a/libraries/botbuilder-dialogs/tests/choices/test_choice_factory.py b/libraries/botbuilder-dialogs/tests/choices/test_choice_factory.py index f249c3772..fca2f81d5 100644 --- a/libraries/botbuilder-dialogs/tests/choices/test_choice_factory.py +++ b/libraries/botbuilder-dialogs/tests/choices/test_choice_factory.py @@ -5,17 +5,262 @@ from typing import List from botbuilder.core import CardFactory, MessageFactory -from botbuilder.dialogs.choices import ( - ChoiceFactory, - Choice, - ChoiceFactoryOptions +from botbuilder.dialogs.choices import Choice, ChoiceFactory, ChoiceFactoryOptions +from botbuilder.schema import ( + ActionTypes, + Activity, + ActivityTypes, + Attachment, + AttachmentLayoutTypes, + CardAction, + HeroCard, + InputHints, + SuggestedActions, ) -from botbuilder.schema import ActionTypes, Activity, CardAction, HeroCard, InputHints +from botframework.connector import Channels class ChoiceFactoryTest(unittest.TestCase): - color_choices = [Choice("red"), Choice("green"), Choice("blue")] + color_choices: List[Choice] = [Choice("red"), Choice("green"), Choice("blue")] + choices_with_actions: List[Choice] = [ + Choice( + "ImBack", + action=CardAction( + type=ActionTypes.im_back, title="ImBack Action", value="ImBack Value" + ), + ), + Choice( + "MessageBack", + action=CardAction( + type=ActionTypes.message_back, + title="MessageBack Action", + value="MessageBack Value", + ), + ), + Choice( + "PostBack", + action=CardAction( + type=ActionTypes.post_back, + title="PostBack Action", + value="PostBack Value", + ), + ), + ] def test_inline_should_render_choices_inline(self): activity = ChoiceFactory.inline(ChoiceFactoryTest.color_choices, "select from:") self.assertEqual("select from: (1) red, (2) green, or (3) blue", activity.text) + + def test_ShouldRenderChoicesAsAList(self): + activity = ChoiceFactory.list(ChoiceFactoryTest.color_choices, "select from:") + self.assertEqual( + "select from:\n\n 1. red\n 2. green\n 3. blue", activity.text + ) + + def test_should_render_unincluded_numbers_choices_as_a_list(self): + activity = ChoiceFactory.list( + ChoiceFactoryTest.color_choices, + "select from:", + options=ChoiceFactoryOptions(include_numbers=False), + ) + self.assertEqual( + "select from:\n\n - red\n - green\n - blue", activity.text + ) + + def test_should_render_choices_as_suggested_actions(self): + expected = Activity( + type=ActivityTypes.message, + text="select from:", + input_hint=InputHints.expecting_input, + suggested_actions=SuggestedActions( + actions=[ + CardAction(type=ActionTypes.im_back, value="red", title="red"), + CardAction(type=ActionTypes.im_back, value="green", title="green"), + CardAction(type=ActionTypes.im_back, value="blue", title="blue"), + ] + ), + ) + + activity = ChoiceFactory.suggested_action( + ChoiceFactoryTest.color_choices, "select from:" + ) + + self.assertEqual(expected, activity) + + def test_should_render_choices_as_hero_card(self): + expected = Activity( + type=ActivityTypes.message, + input_hint=InputHints.expecting_input, + attachment_layout=AttachmentLayoutTypes.list, + attachments=[ + Attachment( + content=HeroCard( + text="select from:", + buttons=[ + CardAction( + type=ActionTypes.im_back, value="red", title="red" + ), + CardAction( + type=ActionTypes.im_back, value="green", title="green" + ), + CardAction( + type=ActionTypes.im_back, value="blue", title="blue" + ), + ], + ), + content_type="application/vnd.microsoft.card.hero", + ) + ], + ) + + activity = ChoiceFactory.hero_card( + ChoiceFactoryTest.color_choices, "select from:" + ) + + self.assertEqual(expected, activity) + + def test_should_automatically_choose_render_style_based_on_channel_type(self): + expected = Activity( + type=ActivityTypes.message, + text="select from:", + input_hint=InputHints.expecting_input, + suggested_actions=SuggestedActions( + actions=[ + CardAction(type=ActionTypes.im_back, value="red", title="red"), + CardAction(type=ActionTypes.im_back, value="green", title="green"), + CardAction(type=ActionTypes.im_back, value="blue", title="blue"), + ] + ), + ) + activity = ChoiceFactory.for_channel( + Channels.emulator, ChoiceFactoryTest.color_choices, "select from:" + ) + + self.assertEqual(expected, activity) + + def test_should_choose_correct_styles_for_cortana(self): + expected = Activity( + type=ActivityTypes.message, + input_hint=InputHints.expecting_input, + attachment_layout=AttachmentLayoutTypes.list, + attachments=[ + Attachment( + content=HeroCard( + text="select from:", + buttons=[ + CardAction( + type=ActionTypes.im_back, value="red", title="red" + ), + CardAction( + type=ActionTypes.im_back, value="green", title="green" + ), + CardAction( + type=ActionTypes.im_back, value="blue", title="blue" + ), + ], + ), + content_type="application/vnd.microsoft.card.hero", + ) + ], + ) + + activity = ChoiceFactory.for_channel( + Channels.cortana, ChoiceFactoryTest.color_choices, "select from:" + ) + self.assertEqual(expected, activity) + + def test_should_choose_correct_styles_for_teams(self): + expected = Activity( + type=ActivityTypes.message, + input_hint=InputHints.expecting_input, + attachment_layout=AttachmentLayoutTypes.list, + attachments=[ + Attachment( + content=HeroCard( + text="select from:", + buttons=[ + CardAction( + type=ActionTypes.im_back, value="red", title="red" + ), + CardAction( + type=ActionTypes.im_back, value="green", title="green" + ), + CardAction( + type=ActionTypes.im_back, value="blue", title="blue" + ), + ], + ), + content_type="application/vnd.microsoft.card.hero", + ) + ], + ) + activity = ChoiceFactory.for_channel( + Channels.ms_teams, ChoiceFactoryTest.color_choices, "select from:" + ) + self.assertEqual(expected, activity) + + def test_should_include_choice_actions_in_suggested_actions(self): + expected = Activity( + type=ActivityTypes.message, + text="select from:", + input_hint=InputHints.expecting_input, + suggested_actions=SuggestedActions( + actions=[ + CardAction( + type=ActionTypes.im_back, + value="ImBack Value", + title="ImBack Action", + ), + CardAction( + type=ActionTypes.message_back, + value="MessageBack Value", + title="MessageBack Action", + ), + CardAction( + type=ActionTypes.post_back, + value="PostBack Value", + title="PostBack Action", + ), + ] + ), + ) + activity = ChoiceFactory.suggested_action( + ChoiceFactoryTest.choices_with_actions, "select from:" + ) + self.assertEqual(expected, activity) + + def test_ShouldIncludeChoiceActionsInHeroCards(self): + expected = Activity( + type=ActivityTypes.message, + input_hint=InputHints.expecting_input, + attachment_layout=AttachmentLayoutTypes.list, + attachments=[ + Attachment( + content=HeroCard( + text="select from:", + buttons=[ + CardAction( + type=ActionTypes.im_back, + value="ImBack Value", + title="ImBack Action", + ), + CardAction( + type=ActionTypes.message_back, + value="MessageBack Value", + title="MessageBack Action", + ), + CardAction( + type=ActionTypes.post_back, + value="PostBack Value", + title="PostBack Action", + ), + ], + ), + content_type="application/vnd.microsoft.card.hero", + ) + ], + ) + activity = ChoiceFactory.hero_card( + ChoiceFactoryTest.choices_with_actions, "select from:" + ) + self.assertEqual(expected, activity) diff --git a/libraries/botframework-connector/botframework/connector/channels.py b/libraries/botframework-connector/botframework/connector/channels.py index 9c7d56e5b..0131eff48 100644 --- a/libraries/botframework-connector/botframework/connector/channels.py +++ b/libraries/botframework-connector/botframework/connector/channels.py @@ -1,56 +1,57 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from enum import Enum -class Channels(object): +class Channels(str, Enum): """ Ids of channels supported by the Bot Builder. """ - Console = "console" + console = "console" """Console channel.""" - Cortana = "cortana" + cortana = "cortana" """Cortana channel.""" - Directline = "directline" + direct_line = "directline" """Direct Line channel.""" - Email = "email" + email = "email" """Email channel.""" - Emulator = "emulator" + emulator = "emulator" """Emulator channel.""" - Facebook = "facebook" + facebook = "facebook" """Facebook channel.""" - Groupme = "groupme" + groupme = "groupme" """Group Me channel.""" - Kik = "kik" + kik = "kik" """Kik channel.""" - Line = "line" + line = "line" """Line channel.""" - Msteams = "msteams" + ms_teams = "msteams" """MS Teams channel.""" - Skype = "skype" + skype = "skype" """Skype channel.""" - Skypeforbusiness = "skypeforbusiness" + skype_for_business = "skypeforbusiness" """Skype for Business channel.""" - Slack = "slack" + slack = "slack" """Slack channel.""" - Sms = "sms" + sms = "sms" """SMS (Twilio) channel.""" - Telegram = "telegram" + telegram = "telegram" """Telegram channel.""" - Webchat = "webchat" + webchat = "webchat" """WebChat channel.""" From b88f8a9f446bfd3e3468eba23e77f6a3455d516e Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Fri, 19 Apr 2019 16:14:49 -0700 Subject: [PATCH 23/73] Add component_dialog, fix tons of things --- .../botbuilder/core/__init__.py | 17 +- .../botbuilder/core/activity_handler.py | 6 +- .../botbuilder/core/adapters/__init__.py | 12 + .../core/{ => adapters}/test_adapter.py | 12 +- .../botbuilder/core/bot_state.py | 255 +++++++++++++----- .../botbuilder/core/state_property_info.py | 2 +- .../botbuilder/core/turn_context.py | 17 +- libraries/botbuilder-core/setup.py | 2 +- .../botbuilder/dialogs/__init__.py | 22 +- .../botbuilder/dialogs/component_dialog.py | 144 ++++++++++ .../botbuilder/dialogs/dialog.py | 6 +- .../botbuilder/dialogs/dialog_context.py | 57 ++-- .../botbuilder/dialogs/dialog_set.py | 25 +- .../botbuilder/dialogs/dialog_state.py | 13 +- .../botbuilder/dialogs/dialog_turn_result.py | 8 +- .../dialogs/prompts/confirm_prompt.py | 21 +- .../dialogs/prompts/number_prompt.py | 62 +++++ .../botbuilder/dialogs/waterfall_dialog.py | 30 ++- .../dialogs/waterfall_step_context.py | 47 +++- .../tests/test_waterfall.py | 92 +++++-- 20 files changed, 652 insertions(+), 198 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/adapters/__init__.py rename libraries/botbuilder-core/botbuilder/core/{ => adapters}/test_adapter.py (95%) create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index 53d6558f6..3eea39743 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -6,23 +6,26 @@ # -------------------------------------------------------------------------- from .about import __version__ - +from .activity_handler import ActivityHandler +from .assertions import BotAssert from .bot_adapter import BotAdapter from .bot_framework_adapter import BotFrameworkAdapter, BotFrameworkAdapterSettings -from .turn_context import TurnContext from .bot_state import BotState from .card_factory import CardFactory from .conversation_state import ConversationState from .memory_storage import MemoryStorage from .message_factory import MessageFactory from .middleware_set import AnonymousReceiveMiddleware, Middleware, MiddlewareSet +from .state_property_accessor import StatePropertyAccessor +from .state_property_info import StatePropertyInfo from .storage import Storage, StoreItem, StorageKeyFactory, calculate_change_hash -from .test_adapter import TestAdapter +from .turn_context import TurnContext from .user_state import UserState -__all__ = ['AnonymousReceiveMiddleware', +__all__ = ['ActivityHandler', + 'AnonymousReceiveMiddleware', 'BotAdapter', - 'TurnContext', + 'BotAssert', 'BotFrameworkAdapter', 'BotFrameworkAdapterSettings', 'BotState', @@ -33,9 +36,11 @@ 'MessageFactory', 'Middleware', 'MiddlewareSet', + 'StatePropertyAccessor', + 'StatePropertyInfo', 'Storage', 'StorageKeyFactory', 'StoreItem', - 'TestAdapter', + 'TurnContext', 'UserState', '__version__'] diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index b1e1600eb..4ddeab162 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -1,5 +1,9 @@ import asyncio -from botbuilder.schema import ActivityTypes, TurnContext, ChannelAccount +from botbuilder.schema import ( + ActivityTypes, + ChannelAccount + ) +from .turn_context import TurnContext class ActivityHandler: diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/__init__.py b/libraries/botbuilder-core/botbuilder/core/adapters/__init__.py new file mode 100644 index 000000000..cea62f3b6 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/adapters/__init__.py @@ -0,0 +1,12 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .test_adapter import TestAdapter, TestFlow + +__all__ = [ + "TestAdapter", + "TestFlow"] diff --git a/libraries/botbuilder-core/botbuilder/core/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py similarity index 95% rename from libraries/botbuilder-core/botbuilder/core/test_adapter.py rename to libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 3cd64ee40..3511ef729 100644 --- a/libraries/botbuilder-core/botbuilder/core/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -6,17 +6,18 @@ from datetime import datetime from typing import Coroutine, List from copy import copy -from botbuilder.core import BotAdapter, TurnContext +from ..bot_adapter import BotAdapter +from ..turn_contect import TurnContext from botbuilder.schema import (ActivityTypes, Activity, ConversationAccount, ConversationReference, ChannelAccount, ResourceResponse) class TestAdapter(BotAdapter): - def __init__(self, logic: Coroutine=None, template: ConversationReference=None): + def __init__(self, logic: Coroutine=None, conversation: ConversationReference=None, send_trace_activity: bool = False): """ Creates a new TestAdapter instance. :param logic: - :param template: + :param conversation: A reference to the conversation to begin the adapter state with. """ super(TestAdapter, self).__init__() self.logic = logic @@ -102,7 +103,6 @@ async def receive_activity(self, activity): if value is not None and key != 'additional_properties': setattr(request, key, value) - if not request.type: request.type = ActivityTypes.message if not request.id: self._next_id += 1 @@ -112,12 +112,12 @@ async def receive_activity(self, activity): context = TurnContext(self, request) return await self.run_middleware(context, self.logic) - async def send(self, user_says): + async def send(self, user_says) -> object: """ Sends something to the bot. This returns a new `TestFlow` instance which can be used to add additional steps for inspecting the bots reply and then sending additional activities. :param user_says: - :return: + :return: A new instance of the TestFlow object """ return TestFlow(await self.receive_activity(user_says), self) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 0ca689e52..663307f67 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -8,71 +8,172 @@ from botbuilder.core.state_property_accessor import StatePropertyAccessor from botbuilder.core import turn_context from _ast import Try +from abc import abstractmethod +from typing import Callable, Dict + + +class CachedBotState: + """ + Internal cached bot state. + """ + def __init__(self, state: Dict[str, object] = None) : + self._state = state if state != None else {} + self._hash = self.compute_hash(state) + + @property + def state(self) -> Dict[str, object]: + return self._state; + @state.setter + def state(self, state: Dict[str, object]): + self._state = State + + @property + def hash(self) -> str: + return self._hash + + @hash.setter + def hash(self, hash: str): + self._hash = hash; + + @property + def is_changed(self) -> bool: + return hash != compute_hash(state) + + def compute_hash(self, obj: object) -> str: + # TODO: Should this be compatible with C# JsonConvert.SerializeObject ? + return str(obj) class BotState(PropertyManager): def __init__(self, storage: Storage, context_service_key: str): self.state_key = 'state' - self.storage = storage - self._context_storage_key = context_service_key - - + self._storage = storage + self._context_service_key = context_service_key def create_property(self, name:str) -> StatePropertyAccessor: - """Create a property definition and register it with this BotState. - Parameters - ---------- - name - The name of the property. - - Returns - ------- - StatePropertyAccessor - If successful, the state property accessor created. + """ + Create a property definition and register it with this BotState. + :param name: The name of the property. + :param force: + :return: If successful, the state property accessor created. """ if not name: raise TypeError('BotState.create_property(): BotState cannot be None.') return BotStatePropertyAccessor(self, name); - async def load(self, turn_context: TurnContext, force: bool = False): - """Reads in the current state object and caches it in the context object for this turm. - Parameters - ---------- - turn_context - The context object for this turn. - force - Optional. True to bypass the cache. + async def load(self, turn_context: TurnContext, force: bool = False) -> None: """ - if not turn_context: + Reads in the current state object and caches it in the context object for this turm. + :param turn_context: The context object for this turn. + :param force: Optional. True to bypass the cache. + """ + if turn_context == None: raise TypeError('BotState.load(): turn_context cannot be None.') - cached_state = turn_context.turn_state.get(self._context_storage_key) - storage_key = get_storage_key(turn_context) + cached_state = turn_context.turn_state.get(self._context_service_key) + storage_key = self.get_storage_key(turn_context) if (force or not cached_state or not cached_state.state) : - items = await _storage.read([storage_key]) + items = await self._storage.read([storage_key]) val = items.get(storage_key) - turn_context.turn_state[self._context_storage_key] = CachedBotState(val) - - async def on_process_request(self, context, next_middleware): - """Reads and writes state for your bot to storage. - Parameters - ---------- - context - The Turn Context. - next_middleware - The next middleware component + turn_context.turn_state[self._context_service_key] = CachedBotState(val) + + async def save_changes(self, turn_context: TurnContext, force: bool = False) -> None: + """ + If it has changed, writes to storage the state object that is cached in the current context object for this turn. + :param turn_context: The context object for this turn. + :param force: Optional. True to save state to storage whether or not there are changes. + """ + if turn_context == None: + raise TypeError('BotState.save_changes(): turn_context cannot be None.') + + cached_state = turn_context.turn_state.get(self._context_service_key) + + if force or (cached_state != None and cached_state.is_changed == True): + storage_key = self.get_storage_key(turn_context) + changes : Dict[str, object] = { key: cached_state.state } + await self._storage.write(changes) + cached_state.hash = cached_state.compute_hash() - Returns - ------- + async def clear_state(self, turn_context: TurnContext): + """ + Clears any state currently stored in this state scope. + NOTE: that save_changes must be called in order for the cleared state to be persisted to the underlying store. + + :param turn_context: The context object for this turn. + :return: None + """ + if turn_context == None: + raise TypeError('BotState.clear_state(): turn_context cannot be None.') + + # Explicitly setting the hash will mean IsChanged is always true. And that will force a Save. + cache_value = CachedBotState() + cache_value.hash = '' + turn_context.turn_state[self._context_service_key] = cache_value; + + async def delete(self, turn_context: TurnContext) -> None: + """ + Delete any state currently stored in this state scope. + + :param turn_context: The context object for this turn. + :return: None + """ + if turn_context == None: + raise TypeError('BotState.delete(): turn_context cannot be None.') + + turn_context.turn_state.pop(self._context_service_key) + + storage_key = get_storage_key(turn_context) + await self._storage.delete({ storage_key }) + + @abstractmethod + async def get_storage_key(self, turn_context: TurnContext) -> str: + raise NotImplementedError() + + async def get_property_value(self, turn_context: TurnContext, property_name: str): + if turn_context == None: + raise TypeError('BotState.get_property_value(): turn_context cannot be None.') + if not property_name: + raise TypeError('BotState.get_property_value(): property_name cannot be None.') + cached_state = turn_context.turn_state.get(self._context_service_key) + + # if there is no value, this will throw, to signal to IPropertyAccesor that a default value should be computed + # This allows this to work with value types + return cached_state.state[property_name] + + async def delete_property(self, turn_context: TurnContext, property_name: str) -> None: + """ + Deletes a property from the state cache in the turn context. + + :param turn_context: The context object for this turn. + :param property_name: The name of the property to delete. + :return: None """ - await self.read(context, True) - # For libraries like aiohttp, the web.Response need to be bubbled up from the process_activity logic, which is - # the results are stored from next_middleware() - logic_results = await next_middleware() - await self.write(context) - return logic_results + if turn_context == None: + raise TypeError('BotState.delete_property(): turn_context cannot be None.') + if not property_name: + raise TypeError('BotState.delete_property(): property_name cannot be None.') + cached_state = turn_context.turn_state.get(self._context_service_key) + cached_state.state.remove(property_name) + async def set_property_value(self, turn_context: TurnContext, property_name: str, value: object) -> None: + """ + Deletes a property from the state cache in the turn context. + + :param turn_context: The context object for this turn. + :param property_name: The value to set on the property. + :return: None + """ + + if turn_context == None: + raise TypeError('BotState.delete_property(): turn_context cannot be None.') + if not property_name: + raise TypeError('BotState.delete_property(): property_name cannot be None.') + cached_state = turn_context.turn_state.get(self._context_service_key) + cached_state.state[property_name] = value + + + async def read(self, context: TurnContext, force: bool=False): """ Reads in and caches the current state object for a turn. @@ -113,28 +214,44 @@ async def write(self, context: TurnContext, force: bool=False): cached['hash'] = calculate_change_hash(cached['state']) context.services[self.state_key] = cached - async def clear(self, context: TurnContext): + + async def get(self, context: TurnContext, default_value_factory: Callable): """ - Clears the current state object for a turn. - :param context: + Get the property value. + The semantics are intended to be lazy, note the use of load at the start. + + :param context: The context object for this turn. + :param default_value_factory: Defines the default value. Invoked when no value + been set for the requested state property. If defaultValueFactory is + defined as None, a TypeError exception will be thrown if the underlying + property is not set. :return: """ - cached = context.services.get(self.state_key) - if cached is not None: - cached['state'] = StoreItem() - context.services[self.state_key] = cached - - async def get(self, context: TurnContext): + await _bot_state.load(turn_context, False) + try: + return await _bot_state.get_property_value(turn_context, name) + except: + if default_value_factory == None: + raise TypeError('BotState.get(): default_value_factory None and cannot set property.') + result = default_value_factory(); + + # save default value for any further calls + await set(turn_context, result) + return result + + async def set(self, turn_context: TurnContext, value: object) -> None: """ - Returns a cached state object or undefined if not cached. - :param context: - :return: + Set the property value. + The semantics are intended to be lazy, note the use of load at the start. + + :param context: The context object for this turn. + :param value: The value to set. + :return: None """ - cached = context.services.get(self.state_key) - state = None - if isinstance(cached, dict) and isinstance(cached['state'], StoreItem): - state = cached['state'] - return state + await self._bot_state.load(turn_context, False) + await self._bot_state.set_property_value(turn_context, name) + + class BotStatePropertyAccessor(StatePropertyAccessor): @@ -144,14 +261,14 @@ def __init__(self, bot_state: BotState, name: str): @property def name(self) -> str: - return _name; + return self._name; - async def delete(self, turn_context: TurnContext): + async def delete(self, turn_context: TurnContext) -> None: await self._bot_state.load(turn_context, False) await self._bot_state.delete_property_value(turn_context, name) - async def get(self, turn_context: TurnContext, default_value_factory): - await self._bot_state.load(turn_context, false) + async def get(self, turn_context: TurnContext, default_value_factory) -> object: + await self._bot_state.load(turn_context, False) try: return await _bot_state.get_property_value(turn_context, name) except: @@ -160,9 +277,9 @@ async def get(self, turn_context: TurnContext, default_value_factory): return None result = default_value_factory() # save default value for any further calls - await set(turn_context, result) + await self.set(turn_context, result) return result - async def set(self, turn_context: TurnContext, value): - await _bot_state.load(turn_context, false) - await _bot_state.set_property_value(turn_context, name) + async def set(self, turn_context: TurnContext, value: object) -> None: + await self._bot_state.load(turn_context, False) + await self._bot_state.set_property_value(turn_context, self.name, value) diff --git a/libraries/botbuilder-core/botbuilder/core/state_property_info.py b/libraries/botbuilder-core/botbuilder/core/state_property_info.py index 279099c8f..d63277578 100644 --- a/libraries/botbuilder-core/botbuilder/core/state_property_info.py +++ b/libraries/botbuilder-core/botbuilder/core/state_property_info.py @@ -3,7 +3,7 @@ from abc import ABC -class StatePropertyAccessor(ABC): +class StatePropertyInfo(ABC): @property def name(self): raise NotImplementedError(); \ No newline at end of file diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index a0563e865..952d33f01 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -4,8 +4,13 @@ import asyncio from copy import copy from uuid import uuid4 -from typing import List, Callable, Union -from botbuilder.schema import Activity, ConversationReference, ResourceResponse +from typing import List, Callable, Union, Dict +from botbuilder.schema import ( + Activity, + ConversationReference, + ResourceResponse + ) +from .assertions import BotAssert class TurnContext(object): @@ -32,13 +37,13 @@ def __init__(self, adapter_or_context, request: Activity=None): if self.activity is None: raise TypeError('TurnContext must be instantiated with a request parameter of type Activity.') - # TODO: Make real turn-state-collection - self.turn_state = [] + self._turn_state = {} @property - def turn_state(self): - self.turn_state + def turn_state(self) -> Dict[str, object]: + return self._turn_state + def copy_to(self, context: 'TurnContext') -> None: """ diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index a9c3d4e49..d04d82813 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -24,7 +24,7 @@ keywords=['BotBuilderCore', 'bots', 'ai', 'botframework', 'botbuilder'], long_description=package_info['__summary__'], license=package_info['__license__'], - packages=['botbuilder.core'], + packages=['botbuilder.core', 'botbuilder.core.adapters'], install_requires=REQUIRES, classifiers=[ 'Programming Language :: Python :: 3.6', diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py index 5cb6fb51b..5a109fd24 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py @@ -6,14 +6,28 @@ # -------------------------------------------------------------------------- from .about import __version__ - +from .component_dialog import ComponentDialog from .dialog_context import DialogContext -from .dialog import Dialog +from .dialog_instance import DialogInstance +from .dialog_reason import DialogReason from .dialog_set import DialogSet from .dialog_state import DialogState +from .dialog_turn_result import DialogTurnResult +from .dialog_turn_status import DialogTurnStatus +from .dialog import Dialog +from .waterfall_dialog import WaterfallDialog +from .waterfall_step_context import WaterfallStepContext -__all__ = ['Dialog', - 'DialogContext', +__all__ = [ + 'ComponentDialog', + 'DialogContext', + 'DialogInstance', + 'DialogReason', 'DialogSet', 'DialogState', + 'DialogTurnResult', + 'DialogTurnStatus', + 'Dialog', + 'WaterfallDialog', + 'WaterfallStepContext' '__version__'] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index e69de29bb..0e2824818 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict +from botbuilder.core import ( + TurnContext + ) +from .dialog import Dialog +from .dialog_set import DialogSet +from .dialog_context import DialogContext +from .dialog_turn_result import DialogTurnResult +from .dialog_state import DialogState +from .dialog_turn_status import DialogTurnStatus +from .dialog_reason import DialogReason +from .dialog_instance import DialogInstance + + +class ComponentDialog(Dialog): + persisted_dialog_state = "dialogs" + + def __init__(self, dialog_id: str): + super(ComponentDialog, self).__init__(dialog_id) + + if dialog_id is None: + raise TypeError('ComponentDialog(): dialog_id cannot be None.') + + self._dialogs = DialogSet() + + # TODO: Add TelemetryClient + + @property + def initial_dialog_id(self) -> str: + """Gets the ID of the initial dialog id. + + :param: + :return str:ID of the dialog this instance is for. + """ + return self._id + + @initial_dialog_id.setter + def initial_dialog_id(self, value: str) -> None: + """Sets the ID of the initial dialog id. + + :param value: ID of the dialog this instance is for. + :return: + """ + self._id = value + + async def begin_dialog(self, outer_dc: DialogContext, options: object = None) -> DialogTurnResult: + if outer_dc is None: + raise TypeError('ComponentDialog.begin_dialog(): outer_dc cannot be None.') + + # Start the inner dialog. + dialog_state = DialogState() + outer_dc.active_dialog.state[persisted_dialog_state] = dialog_state + inner_dc = DialogContext(_dialogs, outer_dc.context, dialog_state) + inner_dc.parent = outer_dc + turn_result = await on_begin_dialog(inner_dc, options) + + # Check for end of inner dialog + if turnResult.Status != DialogTurnStatus.Waiting: + # Return result to calling dialog + return await EndComponentAsync(outerDc, turnResult.Result, cancellationToken).ConfigureAwait(false); + else: + # Just signal waiting + return Dialog.EndOfTurn; + + async def continue_dialog(self, outer_dc: DialogContext) -> DialogTurnResult: + if outer_dc is None: + raise TypeError('ComponentDialog.begin_dialog(): outer_dc cannot be None.') + # Continue execution of inner dialog. + dialog_state = outer_dc.active_dialog.state[persisted_dialog_state] + inner_dc = DialogContext(_dialogs, outer_dc.context, dialog_state) + inner_dc.parent = outer_dc + turn_result = await on_continue_dialog(inner_dc) + + if turn_result.status != DialogTurnStatus.Waiting: + return await end_component(outer_dc, turn_result.result) + else: + return Dialog.end_of_turn + + async def resume_dialog(self, outer_dc: DialogContext, reason: DialogReason, result: object = None) -> DialogTurnResult: + # Containers are typically leaf nodes on the stack but the dev is free to push other dialogs + # on top of the stack which will result in the container receiving an unexpected call to + # resume_dialog() when the pushed on dialog ends. + # To avoid the container prematurely ending we need to implement this method and simply + # ask our inner dialog stack to re-prompt. + await reprompt_dialog(outer_dc.context, outer_dc.active_dialog) + return Dialog.end_of_turn + + async def reprompt_dialog(self, turn_context: TurnContext, instance: DialogInstance) -> None: + # Delegate to inner dialog. + dialog_state = instance.state[persisted_dialog_state] + inner_dc = DialogContext(_dialogs, turn_context, dialogState) + await inner_dc.reprompt_dialog() + + # Notify component + await on_reprompt_dialog(turn_context, instance) + + + async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, reason: DialogReason) -> None: + # Forward cancel to inner dialogs + if reason == DialogReason.CancelCalled: + dialog_state = instance.State[persisted_dialog_state] + inner_dc = DialogContext(_dialogs, turn_context, dialog_state) + await inner_dc.cancel_all_dialogs() + await on_end_dialog(turn_context, instance, reason) + + def add_dialog(self, dialog: Dialog) -> object: + """ + Adds a dialog to the component dialog. + Adding a new dialog will inherit the BotTelemetryClient of the ComponentDialog. + :param dialog: The dialog to add. + :return: The updated ComponentDialog + """ + self._dialogs.Add(dialog); + if not tnitial_dialog_id: + initial_dialog_id = dialog.id + return self + + def find_dialog(dialog_id: str ) -> Dialog: + """ + Finds a dialog by ID. + Adding a new dialog will inherit the BotTelemetryClient of the ComponentDialog. + :param dialog_id: The dialog to add. + :return: The dialog; or None if there is not a match for the ID. + """ + return _dialogs.Find(dialogId); + + + async def on_begin_dialog(self, inner_dc: DialogContext, options: object) -> DialogTurnResult: + return inner_dc.begin_dialog(initial_dialog_id, options) + + async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: + return inner_dc.continue_dialog() + + async def on_end_dialog(self, context: TurnContext, instance: DialogInstance, reason: DialogReason) -> None: + return + + async def on_reprompt_dialog(self, turn_context: TurnContext, instance: DialogInstance) -> None: + return + + async def end_component(self, outer_dc: DialogContext, result: object) -> DialogTurnResult: + return outer_dc.end_dialog(result) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py index b5f6132e9..9f1c23010 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py @@ -15,11 +15,11 @@ def __init__(self, dialog_id: str): raise TypeError('Dialog(): dialogId cannot be None.') self.telemetry_client = None; # TODO: Make this NullBotTelemetryClient() - self.__id = dialog_id; + self._id = dialog_id; @property - def id(self): - return self.__id; + def id(self) -> str: + return self._id; @abstractmethod async def begin_dialog(self, dc, options: object = None): diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index a65c193a6..6114b9d9d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -3,23 +3,25 @@ from .dialog_state import DialogState +from .dialog_turn_status import DialogTurnStatus from .dialog_turn_result import DialogTurnResult from .dialog_reason import DialogReason +from .dialog_instance import DialogInstance from .dialog import Dialog from botbuilder.core.turn_context import TurnContext class DialogContext(): def __init__(self, dialog_set: object, turn_context: TurnContext, state: DialogState): - if dialogs is None: - raise TypeError('DialogContext(): dialogs cannot be None.') + if dialog_set is None: + raise TypeError('DialogContext(): dialog_set cannot be None.') # TODO: Circular dependency with dialog_set: Check type. if turn_context is None: raise TypeError('DialogContext(): turn_context cannot be None.') - self.__turn_context = turn_context; - self.__dialogs = dialogs; - self.__id = dialog_id; - self.__stack = state.dialog_stack; - self.parent; + self._turn_context = turn_context; + self._dialogs = dialog_set; + # self._id = dialog_id; + self._stack = state.dialog_stack; + # self.parent; @property def dialogs(self): @@ -28,7 +30,7 @@ def dialogs(self): :param: :return str: """ - return self.__dialogs + return self._dialogs @property def context(self) -> TurnContext: @@ -37,7 +39,7 @@ def context(self) -> TurnContext: :param: :return str: """ - return self.__turn_context + return self._turn_context @property def stack(self): @@ -46,7 +48,7 @@ def stack(self): :param: :return str: """ - return self.__stack + return self._stack @property @@ -56,8 +58,8 @@ def active_dialog(self): :param: :return str: """ - if (self.__stack and self.__stack.size() > 0): - return self.__stack[0] + if self._stack != None and len(self._stack) > 0: + return self._stack[0] return None @@ -71,20 +73,20 @@ async def begin_dialog(self, dialog_id: str, options: object = None): if (not dialog_id): raise TypeError('Dialog(): dialogId cannot be None.') # Look up dialog - dialog = find_dialog(dialog_id); - if (not dialog): + dialog = await self.find_dialog(dialog_id); + if dialog is None: raise Exception("'DialogContext.begin_dialog(): A dialog with an id of '%s' wasn't found." " The dialog must be included in the current or parent DialogSet." " For example, if subclassing a ComponentDialog you can call add_dialog() within your constructor." % dialog_id); # Push new instance onto stack instance = DialogInstance() instance.id = dialog_id - instance.state = [] + instance.state = {} - stack.insert(0, instance) - - # Call dialog's BeginAsync() method - return await dialog.begin_dialog(this, options) + self._stack.append(instance) + + # Call dialog's begin_dialog() method + return await dialog.begin_dialog(self, options) # TODO: Fix options: PromptOptions instead of object async def prompt(self, dialog_id: str, options) -> DialogTurnResult: @@ -112,9 +114,9 @@ async def continue_dialog(self, dc, reason: DialogReason, result: object): :return: """ # Check for a dialog on the stack - if not active_dialog: + if self.active_dialog != None: # Look up dialog - dialog = find_dialog(active_dialog.id) + dialog = await self.find_dialog(self.active_dialog.id) if not dialog: raise Exception("DialogContext.continue_dialog(): Can't continue dialog. A dialog with an id of '%s' wasn't found." % active_dialog.id) @@ -141,7 +143,7 @@ async def end_dialog(self, context: TurnContext, instance): # Resume previous dialog if not active_dialog: # Look up dialog - dialog = find_dialog(active_dialog.id) + dialog = await self.find_dialog(active_dialog.id) if not dialog: raise Exception("DialogContext.EndDialogAsync(): Can't resume previous dialog. A dialog with an id of '%s' wasn't found." % active_dialog.id) @@ -171,8 +173,9 @@ async def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: ID of the dialog to search for. :return: """ - dialog = dialogs.find(dialog_id); - if (not dialog and parent != None): + dialog = await self.dialogs.find(dialog_id); + + if (dialog == None and parent != None): dialog = parent.find_dialog(dialog_id) return dialog @@ -198,7 +201,7 @@ async def reprompt_dialog(self): # Check for a dialog on the stack if active_dialog != None: # Look up dialog - dialog = find_dialog(active_dialog.id) + dialog = await self.find_dialog(active_dialog.id) if not dialog: raise Exception("DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'." % active_dialog.id) @@ -209,10 +212,10 @@ async def end_active_dialog(reason: DialogReason): instance = active_dialog; if instance != None: # Look up dialog - dialog = find_dialog(instance.id) + dialog = await self.find_dialog(instance.id) if not dialog: # Notify dialog of end await dialog.end_dialog(context, instance, reason) # Pop dialog off stack - stack.pop() \ No newline at end of file + self._stack.pop() \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index be977ee98..acd049a9c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -1,18 +1,20 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - +from .dialog import Dialog from .dialog_state import DialogState from .dialog_turn_result import DialogTurnResult from .dialog_reason import DialogReason -from botbuilder.core.turn_context import TurnContext -from botbuilder.core.state_property_accessor import StatePropertyAccessor +from .dialog_context import DialogContext +from botbuilder.core import ( + TurnContext, + BotAssert, + StatePropertyAccessor + ) from typing import Dict class DialogSet(): - from .dialog import Dialog - from .dialog_context import DialogContext def __init__(self, dialog_state: StatePropertyAccessor): if dialog_state is None: @@ -23,16 +25,17 @@ def __init__(self, dialog_state: StatePropertyAccessor): self._dialogs: Dict[str, object] = {} + async def add(self, dialog: Dialog): """ Adds a new dialog to the set and returns the added dialog. :param dialog: The dialog to add. """ - if not dialog: - raise TypeError('DialogSet(): dialog cannot be None.') + if dialog is None or not isinstance(dialog, Dialog): + raise TypeError('DialogSet.add(): dialog cannot be None and must be a Dialog or derived class.') if dialog.id in self._dialogs: - raise TypeError("DialogSet.Add(): A dialog with an id of '%s' already added." % dialog.id) + raise TypeError("DialogSet.add(): A dialog with an id of '%s' already added." % dialog.id) # dialog.telemetry_client = this._telemetry_client; self._dialogs[dialog.id] = dialog @@ -42,7 +45,7 @@ async def add(self, dialog: Dialog): async def create_context(self, turn_context: TurnContext) -> DialogContext: BotAssert.context_not_null(turn_context) - if not _dialog_state: + if not self._dialog_state: raise RuntimeError("DialogSet.CreateContextAsync(): DialogSet created with a null IStatePropertyAccessor.") state = await self._dialog_state.get(turn_context, lambda: DialogState()) @@ -58,8 +61,8 @@ async def find(self, dialog_id: str) -> Dialog: if (not dialog_id): raise TypeError('DialogContext.find(): dialog_id cannot be None.'); - if dialog_id in _dialogs: - return _dialogs[dialog_id] + if dialog_id in self._dialogs: + return self._dialogs[dialog_id] return None diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index 1003970c3..e7704d927 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -1,13 +1,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from .dialog_instance import DialogInstance +from typing import List class DialogState(): - def __init__(self, stack: []): - if stack is None: - raise TypeError('DialogState(): stack cannot be None.') - self.__dialog_stack = stack + def __init__(self, stack: List[DialogInstance] = None): + if stack == None: + self._dialog_stack = [] + else: + self._dialog_stack = stack @property def dialog_stack(self): - return __dialog_stack; + return self._dialog_stack; diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index 5d0058d91..831a9a7d1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -6,13 +6,13 @@ class DialogTurnResult(): def __init__(self, status: DialogTurnStatus, result:object = None): - self.__status = status - self.__result = result; + self._status = status + self._result = result; @property def status(self): - return __status; + return self._status; @property def result(self): - return __result; + return self._result; diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py index d592c8bab..a97f4eb9e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py @@ -10,23 +10,18 @@ from .prompt_options import PromptOptions from .prompt_recognizer_result import PromptRecognizerResult -class choice_default: - def __init__(self, affirm: Choice, negate: Choice, opts: ChoiceFactoryOptions): - self.affirm = affirm - self.negate = negate - self.opts = opts class ConfirmPrompt(Prompt): # TODO: Fix to reference recognizer to use proper constants choice_defaults : Dict[str, object] = { - 'English': choice_default(Choice("Si"), Choice("No"), ChoiceFactoryOptions(", ", " o ", ", o ", True)), - 'Dutch': choice_default(Choice("Ja"), Choice("Nee"), ChoiceFactoryOptions(", ", " of ", ", of ", True)), - 'English': choice_default(Choice("Yes"), Choice("No"), ChoiceFactoryOptions(", ", " or ", ", or ", True)), - 'French': choice_default(Choice("Oui"), Choice("Non"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)), - 'German': choice_default(Choice("Ja"), Choice("Nein"), ChoiceFactoryOptions(", ", " oder ", ", oder ", True)), - 'Japanese': choice_default(Choice("はい"), Choice("いいえ"), ChoiceFactoryOptions("、 ", " または ", "、 または ", True)), - 'Portuguese': choice_default(Choice("Sim"), Choice("Não"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)), - 'Chinese': choice_default(Choice("是的"), Choice("不"), ChoiceFactoryOptions(", ", " 要么 ", ", 要么 ", True)) + 'English': (Choice("Si"), Choice("No"), ChoiceFactoryOptions(", ", " o ", ", o ", True)), + 'Dutch': (Choice("Ja"), Choice("Nee"), ChoiceFactoryOptions(", ", " of ", ", of ", True)), + 'English': (Choice("Yes"), Choice("No"), ChoiceFactoryOptions(", ", " or ", ", or ", True)), + 'French': (Choice("Oui"), Choice("Non"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)), + 'German': (Choice("Ja"), Choice("Nein"), ChoiceFactoryOptions(", ", " oder ", ", oder ", True)), + 'Japanese': (Choice("はい"), Choice("いいえ"), ChoiceFactoryOptions("、 ", " または ", "、 または ", True)), + 'Portuguese': (Choice("Sim"), Choice("Não"), ChoiceFactoryOptions(", ", " ou ", ", ou ", True)), + 'Chinese': (Choice("是的"), Choice("不"), ChoiceFactoryOptions(", ", " 要么 ", ", 要么 ", True)) } # TODO: PromptValidator diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py new file mode 100644 index 000000000..01ac62e5b --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict +from botbuilder.core.turn_context import TurnContext +from botbuilder.schema import (ActivityTypes, Activity) +from .datetime_resolution import DateTimeResolution +from .prompt import Prompt +from .prompt_options import PromptOptions +from .prompt_recognizer_result import PromptRecognizerResult + + +class NumberPrompt(Prompt): + # TODO: PromptValidator + def __init__(self, dialog_id: str, validator: object, default_locale: str): + super(ConfirmPrompt, self).__init__(dialog_id, validator) + self._default_locale = default_locale; + + @property + def default_locale(self) -> str: + """Gets the locale used if `TurnContext.activity.locale` is not specified. + """ + return self._default_locale + + @default_locale.setter + def default_locale(self, value: str) -> None: + """Gets the locale used if `TurnContext.activity.locale` is not specified. + + :param value: The locale used if `TurnContext.activity.locale` is not specified. + """ + self._default_locale = value + + + async def on_prompt(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions, is_retry: bool): + if not turn_context: + raise TypeError('NumberPrompt.on_prompt(): turn_context cannot be None.') + if not options: + raise TypeError('NumberPrompt.on_prompt(): options cannot be None.') + + if is_retry == True and options.retry_prompt != None: + prompt = turn_context.send_activity(options.retry_prompt) + else: + if options.prompt != None: + turn_context.send_activity(options.prompt) + + + async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions) -> PromptRecognizerResult: + if not turn_context: + raise TypeError('NumberPrompt.on_recognize(): turn_context cannot be None.') + + result = PromptRecognizerResult() + if turn_context.activity.type == ActivityTypes.message: + message = turn_context.activity + + # TODO: Fix constant English with correct constant from text recognizer + culture = turn_context.activity.locale if turn_context.activity.locale != None else 'English' + results = ChoiceRecognizer.recognize_number(message.text, culture) + if results.Count > 0: + result.succeeded = True; + result.value = results[0].resolution["value"] + + return result; diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index 8b71e3b35..42bda7043 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -11,6 +11,7 @@ from .dialog_instance import DialogInstance from .waterfall_step_context import WaterfallStepContext from botbuilder.core import TurnContext +from typing import Coroutine, List class WaterfallDialog(Dialog): @@ -19,7 +20,7 @@ class WaterfallDialog(Dialog): PersistedValues = "values" PersistedInstanceId = "instanceId" - def __init__(self, dialog_id: str, steps: [] = None): + def __init__(self, dialog_id: str, steps: [Coroutine] = None): super(WaterfallDialog, self).__init__(dialog_id) if not steps: self._steps = [] @@ -49,17 +50,18 @@ async def begin_dialog(self, dc: DialogContext, options: object = None) -> Dialo # Initialize waterfall state state = dc.active_dialog.state + instance_id = uuid.uuid1().__str__() - state[PersistedOptions] = options - state[PersistedValues] = Dict[str, object] - state[PersistedInstanceId] = instanceId + state[self.PersistedOptions] = options + state[self.PersistedValues] = {} + state[self.PersistedInstanceId] = instance_id - properties = Dict[str, object] + properties = {} properties['dialog_id'] = id properties['instance_id'] = instance_id # Run first stepkinds - return await run_step(dc, 0, DialogReason.BeginCalled, None) + return await self.run_step(dc, 0, DialogReason.BeginCalled, None) async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: object) -> DialogTurnResult: if not dc: @@ -68,7 +70,7 @@ async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: if dc.context.activity.type != ActivityTypes.message: return Dialog.end_of_turn - return await resume_dialog(dc, DialogReason.ContinueCalled, dc.context.activity.text) + return await self.resume_dialog(dc, DialogReason.ContinueCalled, dc.context.activity.text) async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: object): if not dc: @@ -80,7 +82,7 @@ async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: o # Future Me: # If issues with CosmosDB, see https://github.com/Microsoft/botbuilder-dotnet/issues/871 # for hints. - return run_step(dc, state[StepIndex] + 1, reason, result) + return self.run_step(dc, state[StepIndex] + 1, reason, result) async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, reason: DialogReason): # TODO: Add telemetry logging @@ -90,19 +92,19 @@ async def on_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: # TODO: Add telemetry logging return await self._steps[step_context.index](step_context) - async def run_steps(self, dc: DialogContext, index: int, reason: DialogReason, result: object) -> DialogTurnResult: + async def run_step(self, dc: DialogContext, index: int, reason: DialogReason, result: object) -> DialogTurnResult: if not dc: raise TypeError('WaterfallDialog.run_steps(): dc cannot be None.') - if index < _steps.size: + if index < len(self._steps): # Update persisted step index state = dc.active_dialog.state - state[StepIndex] = index + state[self.StepIndex] = index # Create step context - options = state[PersistedOptions] - values = state[PersistedValues] + options = state[self.PersistedOptions] + values = state[self.PersistedValues] step_context = WaterfallStepContext(self, dc, options, values, index, reason, result) - return await on_step(step_context) + return await self.on_step(step_context) else: # End of waterfall so just return any result to parent return dc.end_dialog(result) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py index b53febd70..1bff31c0f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py @@ -1,15 +1,46 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.dialogs.dialog_context import DialogContext + +from .dialog_context import DialogContext +from .dialog_reason import DialogReason +from .dialog_turn_result import DialogTurnResult +from .dialog_state import DialogState + +from typing import Dict class WaterfallStepContext(DialogContext): - def __init__(self, stack: []): - if stack is None: - raise TypeError('DialogState(): stack cannot be None.') - self.__dialog_stack = stack - + def __init__(self, parent, dc: DialogContext, options: object, values: Dict[str, object], index: int, reason: DialogReason, result: object = None): + super(WaterfallStepContext, self).__init__(dc.dialogs, dc.context, DialogState(dc.stack)) + self._parent = parent + self._next_called = False + self._index = index + self._options = options + self._reason = reason + self._result = result + self._values = values + + @property + def index(self) -> int: + return self._index @property - def dialog_stack(self): - return __dialog_stack; + def options(self) -> object: + return self._options; + @property + def reason(self)->DialogReason: + return self._reason + @property + def result(self) -> object: + return self._result + @property + def values(self) -> Dict[str,object]: + return self._values + + async def next(self, result: object) -> DialogTurnResult: + if self._next_called is True: + raise Exception("WaterfallStepContext.next(): method already called for dialog and step '%s'[%s]." % (self._parent.id, self._index)) + + # Trigger next step + self._next_called = True + return await self._parent.resume_dialog(self, DialogReason.NextCalled, result) diff --git a/libraries/botbuilder-dialogs/tests/test_waterfall.py b/libraries/botbuilder-dialogs/tests/test_waterfall.py index bc380b88e..cbe561932 100644 --- a/libraries/botbuilder-dialogs/tests/test_waterfall.py +++ b/libraries/botbuilder-dialogs/tests/test_waterfall.py @@ -4,35 +4,48 @@ import aiounittest from botbuilder.core.test_adapter import TestAdapter, TestFlow -from botbuilder.core.memory_storage import MemoryStorage -from botbuilder.core.conversation_state import ConversationState -from botbuilder.dialogs.dialog_set import DialogSet -from botbuilder.dialogs.waterfall_dialog import WaterfallDialog -from botbuilder.dialogs.waterfall_step_context import WaterfallStepContext -from botbuilder.dialogs.dialog_turn_result import DialogTurnResult -from botbuilder.dialogs.dialog_context import DialogContext - -async def Waterfall2_Step1(step_context: WaterfallStepContext) -> DialogTurnResult: - await step_context.context.send_activity("step1") - return Dialog.end_of_turn - -async def Waterfall2_Step2(step_context: WaterfallStepContext) -> DialogTurnResult: - await step_context.context.send_activity("step2") - return Dialog.end_of_turn - -async def Waterfall2_Step3(step_context: WaterfallStepContext) -> DialogTurnResult: - await step_context.context.send_activity("step3") - return Dialog.end_of_turn +from botbuilder.schema import ( + Activity + ) +from botbuilder.core import ( + ConversationState, + MemoryStorage, + TurnContext + ) +from botbuilder.dialogs import ( + DialogSet, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, + DialogContext, + DialogTurnStatus + ) class MyWaterfallDialog(WaterfallDialog): def __init__(self, id: str): super(WaterfallDialog, self).__init__(id) + async def Waterfall2_Step1(step_context: WaterfallStepContext) -> DialogTurnResult: + await step_context.context.send_activity("step1") + return Dialog.end_of_turn + + async def Waterfall2_Step2(step_context: WaterfallStepContext) -> DialogTurnResult: + await step_context.context.send_activity("step2") + return Dialog.end_of_turn + + async def Waterfall2_Step3(step_context: WaterfallStepContext) -> DialogTurnResult: + await step_context.context.send_activity("step3") + return Dialog.end_of_turn + self.add_step(Waterfall2_Step1) self.add_step(Waterfall2_Step2) self.add_step(Waterfall2_Step3) +begin_message = Activity() +begin_message.text = 'begin' +begin_message.type = 'message' class WaterfallTests(aiounittest.AsyncTestCase): + def test_waterfall_none_name(self): self.assertRaises(TypeError, (lambda:WaterfallDialog(None))) @@ -40,6 +53,47 @@ def test_watterfall_add_none_step(self): waterfall = WaterfallDialog("test") self.assertRaises(TypeError, (lambda:waterfall.add_step(None))) + async def notest_execute_sequence_waterfall_steps(self): + + + # Create new ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the WaterfallDialog. + dialog_state = convo_state.create_property('dialogState'); + dialogs = DialogSet(dialog_state); + async def step1(step) -> DialogTurnResult: + assert(step, 'hey!') + await step.context.sendActivity('bot responding.') + return Dialog.end_of_turn + + async def step2(step) -> DialogTurnResult: + assert(step) + return await step.end_dialog('ending WaterfallDialog.') + + mydialog = WaterfallDialog('a', { step1, step2 }) + await dialogs.add(mydialog) + + # Initialize TestAdapter + async def exec_test(turn_context: TurnContext) -> None: + dc = await dialogs.create_context(turn_context) + results = await dc.continue_dialog(dc, None, None) + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog('a') + else: + if result.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + await convo_state.save_changes(turn_context) + + adapt = TestAdapter(exec_test) + + await adapt.send(begin_message) + await adapt.assert_reply('bot responding') + await adapt.send('continue') + await adapt.assert_reply('ending WaterfallDialog.') + + + async def test_waterfall_callback(self): convo_state = ConversationState(MemoryStorage()) adapter = TestAdapter() From 71eb54678100795c55ab0fbdc070327f6ab33113 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Sun, 21 Apr 2019 11:53:24 -0700 Subject: [PATCH 24/73] Fix memory_storage, remove tons of semicolons, other cleanup --- .../botbuilder/core/adapters/test_adapter.py | 3 + .../botbuilder/core/bot_state.py | 107 +++--------------- .../botbuilder/core/memory_storage.py | 31 ++--- .../botbuilder/core/storage.py | 2 + .../botbuilder/dialogs/component_dialog.py | 48 ++++---- .../botbuilder/dialogs/dialog.py | 14 +-- .../botbuilder/dialogs/dialog_context.py | 76 ++++++++----- .../botbuilder/dialogs/dialog_set.py | 25 +++- .../botbuilder/dialogs/dialog_state.py | 8 +- .../botbuilder/dialogs/dialog_turn_result.py | 6 +- .../botbuilder/dialogs/prompts/__init__.py | 2 +- .../dialogs/prompts/confirm_prompt.py | 48 ++++---- .../dialogs/prompts/datetime_prompt.py | 12 +- .../dialogs/prompts/number_prompt.py | 7 +- .../botbuilder/dialogs/prompts/prompt.py | 58 +++++----- .../prompts/prompt_validator_context.py | 2 +- .../botbuilder/dialogs/prompts/text_prompt.py | 5 +- .../botbuilder/dialogs/waterfall_dialog.py | 12 +- .../dialogs/waterfall_step_context.py | 2 +- .../tests/test_dialog_set.py | 29 ++--- .../tests/test_waterfall.py | 33 +++--- 21 files changed, 259 insertions(+), 271 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 3511ef729..144415655 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -199,7 +199,9 @@ async def assert_reply(self, expected, description=None, timeout=None) -> 'TestF :param timeout: :return: """ + def default_inspector(reply, description=None): + if isinstance(expected, Activity): validate_activity(reply, expected) else: @@ -250,6 +252,7 @@ def validate_activity(activity, expected) -> None: :return: """ iterable_expected = vars(expected).items() + for attr, value in iterable_expected: if value is not None and attr != 'additional_properties': assert value == getattr(activity, attr) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 663307f67..8f4677e3a 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -22,10 +22,10 @@ def __init__(self, state: Dict[str, object] = None) : @property def state(self) -> Dict[str, object]: - return self._state; + return self._state @state.setter def state(self, state: Dict[str, object]): - self._state = State + self._state = self._state @property def hash(self) -> str: @@ -37,7 +37,7 @@ def hash(self, hash: str): @property def is_changed(self) -> bool: - return hash != compute_hash(state) + return hash != self.compute_hash(self._state) def compute_hash(self, obj: object) -> str: # TODO: Should this be compatible with C# JsonConvert.SerializeObject ? @@ -59,7 +59,7 @@ def create_property(self, name:str) -> StatePropertyAccessor: """ if not name: raise TypeError('BotState.create_property(): BotState cannot be None.') - return BotStatePropertyAccessor(self, name); + return BotStatePropertyAccessor(self, name) async def load(self, turn_context: TurnContext, force: bool = False) -> None: @@ -90,9 +90,9 @@ async def save_changes(self, turn_context: TurnContext, force: bool = False) -> if force or (cached_state != None and cached_state.is_changed == True): storage_key = self.get_storage_key(turn_context) - changes : Dict[str, object] = { key: cached_state.state } + changes : Dict[str, object] = { storage_key: cached_state.state } await self._storage.write(changes) - cached_state.hash = cached_state.compute_hash() + cached_state.hash = cached_state.compute_hash(cached_state.state) async def clear_state(self, turn_context: TurnContext): """ @@ -108,7 +108,7 @@ async def clear_state(self, turn_context: TurnContext): # Explicitly setting the hash will mean IsChanged is always true. And that will force a Save. cache_value = CachedBotState() cache_value.hash = '' - turn_context.turn_state[self._context_service_key] = cache_value; + turn_context.turn_state[self._context_service_key] = cache_value async def delete(self, turn_context: TurnContext) -> None: """ @@ -122,7 +122,7 @@ async def delete(self, turn_context: TurnContext) -> None: turn_context.turn_state.pop(self._context_service_key) - storage_key = get_storage_key(turn_context) + storage_key = self.get_storage_key(turn_context) await self._storage.delete({ storage_key }) @abstractmethod @@ -140,7 +140,7 @@ async def get_property_value(self, turn_context: TurnContext, property_name: str # This allows this to work with value types return cached_state.state[property_name] - async def delete_property(self, turn_context: TurnContext, property_name: str) -> None: + async def delete_property_value(self, turn_context: TurnContext, property_name: str) -> None: """ Deletes a property from the state cache in the turn context. @@ -172,88 +172,6 @@ async def set_property_value(self, turn_context: TurnContext, property_name: str cached_state = turn_context.turn_state.get(self._context_service_key) cached_state.state[property_name] = value - - - async def read(self, context: TurnContext, force: bool=False): - """ - Reads in and caches the current state object for a turn. - :param context: - :param force: - :return: - """ - cached = context.services.get(self.state_key) - - if force or cached is None or ('state' in cached and cached['state'] is None): - key = self._context_storage_key(context) - items = await self.storage.read([key]) - state = items.get(key, StoreItem()) - hash_state = calculate_change_hash(state) - - context.services[self.state_key] = {'state': state, 'hash': hash_state} - return state - - return cached['state'] - - async def write(self, context: TurnContext, force: bool=False): - """ - Saves the cached state object if it's been changed. - :param context: - :param force: - :return: - """ - cached = context.services.get(self.state_key) - - if force or (cached is not None and cached.get('hash', None) != calculate_change_hash(cached['state'])): - key = self._context_storage_key(context) - - if cached is None: - cached = {'state': StoreItem(e_tag='*'), 'hash': ''} - changes = {key: cached['state']} - await self.storage.write(changes) - - cached['hash'] = calculate_change_hash(cached['state']) - context.services[self.state_key] = cached - - - async def get(self, context: TurnContext, default_value_factory: Callable): - """ - Get the property value. - The semantics are intended to be lazy, note the use of load at the start. - - :param context: The context object for this turn. - :param default_value_factory: Defines the default value. Invoked when no value - been set for the requested state property. If defaultValueFactory is - defined as None, a TypeError exception will be thrown if the underlying - property is not set. - :return: - """ - await _bot_state.load(turn_context, False) - try: - return await _bot_state.get_property_value(turn_context, name) - except: - if default_value_factory == None: - raise TypeError('BotState.get(): default_value_factory None and cannot set property.') - result = default_value_factory(); - - # save default value for any further calls - await set(turn_context, result) - return result - - async def set(self, turn_context: TurnContext, value: object) -> None: - """ - Set the property value. - The semantics are intended to be lazy, note the use of load at the start. - - :param context: The context object for this turn. - :param value: The value to set. - :return: None - """ - await self._bot_state.load(turn_context, False) - await self._bot_state.set_property_value(turn_context, name) - - - - class BotStatePropertyAccessor(StatePropertyAccessor): def __init__(self, bot_state: BotState, name: str): self._bot_state = bot_state @@ -261,16 +179,17 @@ def __init__(self, bot_state: BotState, name: str): @property def name(self) -> str: - return self._name; + return self._name async def delete(self, turn_context: TurnContext) -> None: await self._bot_state.load(turn_context, False) - await self._bot_state.delete_property_value(turn_context, name) + await self._bot_state.delete_property_value(turn_context, self._name) async def get(self, turn_context: TurnContext, default_value_factory) -> object: await self._bot_state.load(turn_context, False) try: - return await _bot_state.get_property_value(turn_context, name) + result = await _bot_state.get_property_value(turn_context, name) + return result except: # ask for default value from factory if not default_value_factory: diff --git a/libraries/botbuilder-core/botbuilder/core/memory_storage.py b/libraries/botbuilder-core/botbuilder/core/memory_storage.py index a8b1b4ca5..caa17c552 100644 --- a/libraries/botbuilder-core/botbuilder/core/memory_storage.py +++ b/libraries/botbuilder-core/botbuilder/core/memory_storage.py @@ -36,24 +36,29 @@ async def write(self, changes: Dict[str, StoreItem]): # iterate over the changes for (key, change) in changes.items(): new_value = change - old_value = None + old_state = None + old_state_etag = "" # Check if the a matching key already exists in self.memory # If it exists then we want to cache its original value from memory if key in self.memory: - old_value = self.memory[key] - - write_changes = self.__should_write_changes(old_value, new_value) - - if write_changes: + old_state = self.memory[key] + if "eTag" in old_state: + old_state_etag = old_state["eTag"] + + new_state = new_value + + # Set ETag if applicable + if isinstance(new_value, StoreItem): new_store_item = new_value - if new_store_item is not None: - self._e_tag += 1 - new_store_item.e_tag = str(self._e_tag) - self.memory[key] = new_store_item - else: - raise KeyError("MemoryStorage.write(): `e_tag` conflict or changes do not implement ABC" - " `StoreItem`.") + if not old_state_etag is StoreItem: + if not new_store_item is "*" and new_store_item.e_tag != old_state_etag: + raise Exception("Etag conflict.\nOriginal: %s\r\nCurrent: {%s}" % \ + (new_store_item.e_tag, old_state_etag) ) + new_state.e_tag = str(self._e_tag) + self._e_tag += 1 + self.memory[key] = new_state + except Exception as e: raise e diff --git a/libraries/botbuilder-core/botbuilder/core/storage.py b/libraries/botbuilder-core/botbuilder/core/storage.py index b1fe3d3e6..f3dec310e 100644 --- a/libraries/botbuilder-core/botbuilder/core/storage.py +++ b/libraries/botbuilder-core/botbuilder/core/storage.py @@ -53,6 +53,8 @@ def __str__(self): [f" '{attr}': '{getattr(self, attr)}'" for attr in non_magic_attributes]) + ' }' return output + + StorageKeyFactory = Callable[[TurnContext], str] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py index 0e2824818..f6bdf1828 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/component_dialog.py @@ -12,6 +12,7 @@ from .dialog_state import DialogState from .dialog_turn_status import DialogTurnStatus from .dialog_reason import DialogReason +from .dialog_set import DialogSet from .dialog_instance import DialogInstance @@ -27,6 +28,7 @@ def __init__(self, dialog_id: str): self._dialogs = DialogSet() # TODO: Add TelemetryClient + @property def initial_dialog_id(self) -> str: @@ -52,30 +54,30 @@ async def begin_dialog(self, outer_dc: DialogContext, options: object = None) -> # Start the inner dialog. dialog_state = DialogState() - outer_dc.active_dialog.state[persisted_dialog_state] = dialog_state - inner_dc = DialogContext(_dialogs, outer_dc.context, dialog_state) + outer_dc.active_dialog.state[self.persisted_dialog_state] = dialog_state + inner_dc = DialogContext(self._dialogs, outer_dc.context, dialog_state) inner_dc.parent = outer_dc - turn_result = await on_begin_dialog(inner_dc, options) + turn_result = await self.on_begin_dialog(inner_dc, options) # Check for end of inner dialog - if turnResult.Status != DialogTurnStatus.Waiting: + if turn_result.status != DialogTurnStatus.Waiting: # Return result to calling dialog - return await EndComponentAsync(outerDc, turnResult.Result, cancellationToken).ConfigureAwait(false); + return await self.end_component(outer_dc, turn_result.result) else: # Just signal waiting - return Dialog.EndOfTurn; + return Dialog.end_of_turn async def continue_dialog(self, outer_dc: DialogContext) -> DialogTurnResult: if outer_dc is None: raise TypeError('ComponentDialog.begin_dialog(): outer_dc cannot be None.') # Continue execution of inner dialog. - dialog_state = outer_dc.active_dialog.state[persisted_dialog_state] - inner_dc = DialogContext(_dialogs, outer_dc.context, dialog_state) + dialog_state = outer_dc.active_dialog.state[self.persisted_dialog_state] + inner_dc = DialogContext(self._dialogs, outer_dc.context, dialog_state) inner_dc.parent = outer_dc - turn_result = await on_continue_dialog(inner_dc) + turn_result = await self.on_continue_dialog(inner_dc) if turn_result.status != DialogTurnStatus.Waiting: - return await end_component(outer_dc, turn_result.result) + return await self.end_component(outer_dc, turn_result.result) else: return Dialog.end_of_turn @@ -85,26 +87,26 @@ async def resume_dialog(self, outer_dc: DialogContext, reason: DialogReason, res # resume_dialog() when the pushed on dialog ends. # To avoid the container prematurely ending we need to implement this method and simply # ask our inner dialog stack to re-prompt. - await reprompt_dialog(outer_dc.context, outer_dc.active_dialog) + await self.reprompt_dialog(outer_dc.context, outer_dc.active_dialog) return Dialog.end_of_turn async def reprompt_dialog(self, turn_context: TurnContext, instance: DialogInstance) -> None: # Delegate to inner dialog. - dialog_state = instance.state[persisted_dialog_state] - inner_dc = DialogContext(_dialogs, turn_context, dialogState) + dialog_state = instance.state[self.persisted_dialog_state] + inner_dc = DialogContext(self._dialogs, turn_context, dialog_state) await inner_dc.reprompt_dialog() # Notify component - await on_reprompt_dialog(turn_context, instance) + await self.on_reprompt_dialog(turn_context, instance) async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, reason: DialogReason) -> None: # Forward cancel to inner dialogs if reason == DialogReason.CancelCalled: - dialog_state = instance.State[persisted_dialog_state] - inner_dc = DialogContext(_dialogs, turn_context, dialog_state) + dialog_state = instance.State[self.persisted_dialog_state] + inner_dc = DialogContext(self._dialogs, turn_context, dialog_state) await inner_dc.cancel_all_dialogs() - await on_end_dialog(turn_context, instance, reason) + await self.on_end_dialog(turn_context, instance, reason) def add_dialog(self, dialog: Dialog) -> object: """ @@ -113,23 +115,23 @@ def add_dialog(self, dialog: Dialog) -> object: :param dialog: The dialog to add. :return: The updated ComponentDialog """ - self._dialogs.Add(dialog); - if not tnitial_dialog_id: - initial_dialog_id = dialog.id + self._dialogs.add(dialog) + if not self.initial_dialog_id: + self.initial_dialog_id = dialog.id return self - def find_dialog(dialog_id: str ) -> Dialog: + def find_dialog(self, dialog_id: str ) -> Dialog: """ Finds a dialog by ID. Adding a new dialog will inherit the BotTelemetryClient of the ComponentDialog. :param dialog_id: The dialog to add. :return: The dialog; or None if there is not a match for the ID. """ - return _dialogs.Find(dialogId); + return self._dialogs.find(dialog_id) async def on_begin_dialog(self, inner_dc: DialogContext, options: object) -> DialogTurnResult: - return inner_dc.begin_dialog(initial_dialog_id, options) + return inner_dc.begin_dialog(self.initial_dialog_id, options) async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: return inner_dc.continue_dialog() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py index 9f1c23010..1808bc571 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py @@ -8,18 +8,18 @@ from .dialog_turn_result import DialogTurnResult class Dialog(ABC): - end_of_turn = DialogTurnResult(DialogTurnStatus.Waiting); + end_of_turn = DialogTurnResult(DialogTurnStatus.Waiting) def __init__(self, dialog_id: str): if dialog_id == None or not dialog_id.strip(): raise TypeError('Dialog(): dialogId cannot be None.') self.telemetry_client = None; # TODO: Make this NullBotTelemetryClient() - self._id = dialog_id; + self._id = dialog_id @property def id(self) -> str: - return self._id; + return self._id @abstractmethod async def begin_dialog(self, dc, options: object = None): @@ -40,7 +40,7 @@ async def continue_dialog(self, dc): :return: """ # By default just end the current dialog. - return await dc.EndDialog(None); + return await dc.EndDialog(None) async def resume_dialog(self, dc, reason: DialogReason, result: object): """ @@ -55,7 +55,7 @@ async def resume_dialog(self, dc, reason: DialogReason, result: object): :return: """ # By default just end the current dialog. - return await dc.EndDialog(result); + return await dc.EndDialog(result) # TODO: instance is DialogInstance async def reprompt_dialog(self, context: TurnContext, instance): @@ -64,7 +64,7 @@ async def reprompt_dialog(self, context: TurnContext, instance): :return: """ # No-op by default - return; + return # TODO: instance is DialogInstance async def end_dialog(self, context: TurnContext, instance): """ @@ -72,4 +72,4 @@ async def end_dialog(self, context: TurnContext, instance): :return: """ # No-op by default - return; + return diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index 6114b9d9d..767e899d1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -17,11 +17,11 @@ def __init__(self, dialog_set: object, turn_context: TurnContext, state: DialogS # TODO: Circular dependency with dialog_set: Check type. if turn_context is None: raise TypeError('DialogContext(): turn_context cannot be None.') - self._turn_context = turn_context; - self._dialogs = dialog_set; + self._turn_context = turn_context + self._dialogs = dialog_set # self._id = dialog_id; - self._stack = state.dialog_stack; - # self.parent; + self._stack = state.dialog_stack + self.parent = None @property def dialogs(self): @@ -50,6 +50,26 @@ def stack(self): """ return self._stack + @property + def parent(self) -> 'DialogContext': + """ + Gets the parent DialogContext if any. Used when searching for dialogs to start. + + :param: + :return The parent DialogContext: + """ + return self._parent + + @parent.setter + def parent(self, parent_dialog_context: object): + """ + Sets the parent DialogContext if any. Used when searching for dialogs to start. + + :param parent_dialog_context: The parent dialog context + :return str: + """ + self._parent = parent_dialog_context + @property def active_dialog(self): @@ -73,11 +93,11 @@ async def begin_dialog(self, dialog_id: str, options: object = None): if (not dialog_id): raise TypeError('Dialog(): dialogId cannot be None.') # Look up dialog - dialog = await self.find_dialog(dialog_id); + dialog = await self.find_dialog(dialog_id) if dialog is None: raise Exception("'DialogContext.begin_dialog(): A dialog with an id of '%s' wasn't found." " The dialog must be included in the current or parent DialogSet." - " For example, if subclassing a ComponentDialog you can call add_dialog() within your constructor." % dialog_id); + " For example, if subclassing a ComponentDialog you can call add_dialog() within your constructor." % dialog_id) # Push new instance onto stack instance = DialogInstance() instance.id = dialog_id @@ -103,7 +123,7 @@ async def prompt(self, dialog_id: str, options) -> DialogTurnResult: if (not options): raise TypeError('DialogContext.prompt(): options cannot be None.') - return await begin_dialog(dialog_id, options) + return await self.begin_dialog(dialog_id, options) async def continue_dialog(self, dc, reason: DialogReason, result: object): @@ -126,7 +146,7 @@ async def continue_dialog(self, dc, reason: DialogReason, result: object): return DialogTurnResult(DialogTurnStatus.Empty) # TODO: instance is DialogInstance - async def end_dialog(self, context: TurnContext, instance): + async def end_dialog(self, result: object = None): """ Ends a dialog by popping it off the stack and returns an optional result to the dialog's parent. The parent dialog is the dialog that started the dialog being ended via a call to @@ -138,14 +158,14 @@ async def end_dialog(self, context: TurnContext, instance): :param result: (Optional) result to pass to the parent dialogs. :return: """ - await end_active_dialog(DialogReason.EndCalled); + await self.end_active_dialog(DialogReason.EndCalled) # Resume previous dialog - if not active_dialog: + if not self.active_dialog: # Look up dialog - dialog = await self.find_dialog(active_dialog.id) + dialog = await self.find_dialog(self.active_dialog.id) if not dialog: - raise Exception("DialogContext.EndDialogAsync(): Can't resume previous dialog. A dialog with an id of '%s' wasn't found." % active_dialog.id) + raise Exception("DialogContext.EndDialogAsync(): Can't resume previous dialog. A dialog with an id of '%s' wasn't found." % self.active_dialog.id) # Return result to previous dialog return await dialog.resume_dialog(self, DialogReason.EndCalled, result) @@ -159,9 +179,9 @@ async def cancel_all_dialogs(self): :param result: (Optional) result to pass to the parent dialogs. :return: """ - if (len(stack) > 0): - while (len(stack) > 0): - await end_active_dialog(DialogReason.CancelCalled) + if (len(self.stack) > 0): + while (len(self.stack) > 0): + await self.end_active_dialog(DialogReason.CancelCalled) return DialogTurnResult(DialogTurnStatus.Cancelled) else: return DialogTurnResult(DialogTurnStatus.Empty) @@ -173,13 +193,13 @@ async def find_dialog(self, dialog_id: str) -> Dialog: :param dialog_id: ID of the dialog to search for. :return: """ - dialog = await self.dialogs.find(dialog_id); + dialog = await self.dialogs.find(dialog_id) - if (dialog == None and parent != None): - dialog = parent.find_dialog(dialog_id) + if (dialog == None and self.parent != None): + dialog = self.parent.find_dialog(dialog_id) return dialog - async def replace_dialog(self) -> DialogTurnResult: + async def replace_dialog(self, dialog_id: str, options: object = None) -> DialogTurnResult: """ Ends the active dialog and starts a new dialog in its place. This is particularly useful for creating loops or redirecting to another dialog. @@ -188,10 +208,10 @@ async def replace_dialog(self) -> DialogTurnResult: :return: """ # End the current dialog and giving the reason. - await end_active_dialog(DialogReason.ReplaceCalled) + await self.end_active_dialog(DialogReason.ReplaceCalled) # Start replacement dialog - return await begin_dialog(dialogId, options) + return await self.begin_dialog(dialog_id, options) async def reprompt_dialog(self): """ @@ -199,23 +219,23 @@ async def reprompt_dialog(self): :return: """ # Check for a dialog on the stack - if active_dialog != None: + if self.active_dialog != None: # Look up dialog - dialog = await self.find_dialog(active_dialog.id) + dialog = await self.find_dialog(self.active_dialog.id) if not dialog: - raise Exception("DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'." % active_dialog.id) + raise Exception("DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'." % self.active_dialog.id) # Ask dialog to re-prompt if supported - await dialog.reprompt_dialog(context, active_dialog) + await dialog.reprompt_dialog(self.context, self.active_dialog) - async def end_active_dialog(reason: DialogReason): - instance = active_dialog; + async def end_active_dialog(self, reason: DialogReason): + instance = self.active_dialog if instance != None: # Look up dialog dialog = await self.find_dialog(instance.id) if not dialog: # Notify dialog of end - await dialog.end_dialog(context, instance, reason) + await dialog.end_dialog(self.context, instance, reason) # Pop dialog off stack self._stack.pop() \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index acd049a9c..bf0e1b502 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - +import inspect from .dialog import Dialog from .dialog_state import DialogState from .dialog_turn_result import DialogTurnResult @@ -16,9 +16,22 @@ class DialogSet(): - def __init__(self, dialog_state: StatePropertyAccessor): + def __init__(self, dialog_state: StatePropertyAccessor = None): if dialog_state is None: - raise TypeError('DialogSet(): dialog_state cannot be None.') + frame = inspect.currentframe().f_back + try: + # try to access the caller's "self" + try: + self_obj = frame.f_locals['self'] + except KeyError: + raise TypeError('DialogSet(): dialog_state cannot be None.') + # Only ComponentDialog can initialize with None dialog_state + if not type(self_obj).__name__ is "ComponentDialog": + raise TypeError('DialogSet(): dialog_state cannot be None.') + finally: + # make sure to clean up the frame at the end to avoid ref cycles + del frame + self._dialog_state = dialog_state # self.__telemetry_client = NullBotTelemetryClient.Instance; @@ -59,10 +72,14 @@ async def find(self, dialog_id: str) -> Dialog: :return: The dialog if found, otherwise null. """ if (not dialog_id): - raise TypeError('DialogContext.find(): dialog_id cannot be None.'); + raise TypeError('DialogContext.find(): dialog_id cannot be None.') if dialog_id in self._dialogs: return self._dialogs[dialog_id] return None + def __str__(self): + if len(self._dialogs) <= 0: + return "dialog set empty!" + return ' '.join(map(str, self._dialogs.keys())) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py index e7704d927..0c47a2c76 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_state.py @@ -13,4 +13,10 @@ def __init__(self, stack: List[DialogInstance] = None): @property def dialog_stack(self): - return self._dialog_stack; + return self._dialog_stack + + def __str__(self): + if len(self._dialog_stack) <= 0: + return "dialog stack empty!" + return ' '.join(map(str, self._dialog_stack)) + diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py index 831a9a7d1..55cf7eabc 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_turn_result.py @@ -7,12 +7,12 @@ class DialogTurnResult(): def __init__(self, status: DialogTurnStatus, result:object = None): self._status = status - self._result = result; + self._result = result @property def status(self): - return self._status; + return self._status @property def result(self): - return self._result; + return self._result diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py index b25c0af05..5242a13db 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/__init__.py @@ -19,7 +19,7 @@ __all__ = ["ConfirmPrompt", "DateTimePrompt", "DateTimeResolution", - "NumbersPrompt", + "NumberPrompt", "PromptOptions", "PromptRecognizerResult", "PromptValidatorContext", diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py index a97f4eb9e..426b3f422 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py @@ -27,9 +27,11 @@ class ConfirmPrompt(Prompt): # TODO: PromptValidator def __init__(self, dialog_id: str, validator: object, default_locale: str): super(ConfirmPrompt, self).__init__(dialog_id, validator) - if dialogs is None: - raise TypeError('ConfirmPrompt(): dialogs cannot be None.') + if dialog_id is None: + raise TypeError('ConfirmPrompt(): dialog_id cannot be None.') + # TODO: Port ListStyle self.style = ListStyle.auto + # TODO: Import defaultLocale self.default_locale = defaultLocale self.choice_options = None self.confirm_choices = None @@ -42,51 +44,53 @@ async def on_prompt(self, turn_context: TurnContext, state: Dict[str, object], o # Format prompt to send channel_id = turn_context.activity.channel_id - culture = determine_culture(turn_context.activity) - defaults = choice_defaults[culture] - choice_opts = choice_options if choice_options != None else defaults[2] - confirms = confirm_choices if confirm_choices != None else (defaults[0], defaults[1]) + culture = self.determine_culture(turn_context.activity) + defaults = self.choice_defaults[culture] + choice_opts = self.choice_options if self.choice_options != None else defaults[2] + confirms = self.confirm_choices if self.confirm_choices != None else (defaults[0], defaults[1]) choices = { confirms[0], confirms[1] } if is_retry == True and options.retry_prompt != None: - prompt = append_choices(options.retry_prompt) + prompt = self.append_choices(options.retry_prompt) else: - prompt = append_choices(options.prompt, channel_id, choices, self.style, choice_opts) + prompt = self.append_choices(options.prompt, channel_id, choices, self.style, choice_opts) turn_context.send_activity(prompt) async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions) -> PromptRecognizerResult: if not turn_context: raise TypeError('ConfirmPrompt.on_prompt(): turn_context cannot be None.') - result = PromptRecognizerResult(); + result = PromptRecognizerResult() if turn_context.activity.type == ActivityTypes.message: # Recognize utterance message = turn_context.activity - culture = determine_culture(turn_context.activity) + culture = self.determine_culture(turn_context.activity) + # TODO: Port ChoiceRecognizer results = ChoiceRecognizer.recognize_boolean(message.text, culture) if results.Count > 0: - first = results[0]; + first = results[0] if "value" in first.Resolution: - result.Succeeded = true; - result.Value = first.Resolution["value"].str; + result.Succeeded = True + result.Value = str(first.Resolution["value"]) else: # First check whether the prompt was sent to the user with numbers - if it was we should recognize numbers - defaults = choice_defaults[culture]; - opts = choice_options if choice_options != None else defaults[2] + defaults = self.choice_defaults[culture] + opts = self.choice_options if self.choice_options != None else defaults[2] # This logic reflects the fact that IncludeNumbers is nullable and True is the default set in Inline style if opts.include_numbers.has_value or opts.include_numbers.value: # The text may be a number in which case we will interpret that as a choice. - confirmChoices = confirm_choices if confirm_choices != None else (defaults[0], defaults[1]) - choices = { confirmChoices[0], confirmChoices[1] }; - secondAttemptResults = ChoiceRecognizers.recognize_choices(message.text, choices); + confirmChoices = self.confirm_choices if self.confirm_choices != None else (defaults[0], defaults[1]) + choices = { confirmChoices[0], confirmChoices[1] } + # TODO: Port ChoiceRecognizer + secondAttemptResults = ChoiceRecognizers.recognize_choices(message.text, choices) if len(secondAttemptResults) > 0: result.succeeded = True - result.value = secondAttemptResults[0].resolution.index == 0; + result.value = secondAttemptResults[0].resolution.index == 0 - return result; + return result def determine_culture(self, activity: Activity) -> str: - culture = activity.locale if activity.locale != None else default_locale - if not culture or not culture in choice_defaults: + culture = activity.locale if activity.locale != None else self.default_locale + if not culture or not culture in self.choice_defaults: culture = "English" # TODO: Fix to reference recognizer to use proper constants return culture \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py index 95f29ddef..aa6d29418 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_prompt.py @@ -12,7 +12,7 @@ class DateTimePrompt(Prompt): def __init__(self, dialog_id: str, validator: object = None, default_locale: str = None): super(DateTimePrompt, self).__init__(dialog_id, validator) - self._default_locale = default_locale; + self._default_locale = default_locale @property def default_locale(self) -> str: @@ -48,16 +48,18 @@ async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object] if turn_context.activity.type == ActivityTypes.message: # Recognize utterance message = turn_context.activity - culture = determine_culture(turn_context.activity) + # TODO: English contsant needs to be ported. + culture = message.locale if message.locale != None else "English" + # TOOD: Port Choice Recognizer results = ChoiceRecognizer.recognize_boolean(message.text, culture) if results.Count > 0: - result.succeeded = True; + result.succeeded = True result.value = [] values = results[0] for value in values: - result.value.append(read_resolution(value)) + result.value.append(self.read_resolution(value)) - return result; + return result def read_resolution(self, resolution: Dict[str, str]) -> DateTimeResolution: result = DateTimeResolution() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py index 01ac62e5b..6ef1c292d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/number_prompt.py @@ -14,7 +14,7 @@ class NumberPrompt(Prompt): # TODO: PromptValidator def __init__(self, dialog_id: str, validator: object, default_locale: str): super(ConfirmPrompt, self).__init__(dialog_id, validator) - self._default_locale = default_locale; + self._default_locale = default_locale @property def default_locale(self) -> str: @@ -54,9 +54,10 @@ async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object] # TODO: Fix constant English with correct constant from text recognizer culture = turn_context.activity.locale if turn_context.activity.locale != None else 'English' + # TODO: Port ChoiceRecognizer results = ChoiceRecognizer.recognize_number(message.text, culture) if results.Count > 0: - result.succeeded = True; + result.succeeded = True result.value = results[0].resolution["value"] - return result; + return result diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index dca7c3dfa..569b799e3 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -1,8 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import copy from typing import Dict from .prompt_options import PromptOptions +from .prompt_validator_context import PromptValidatorContext from ..dialog_reason import DialogReason from ..dialog import Dialog from ..dialog_instance import DialogInstance @@ -17,8 +19,8 @@ """ Base class for all prompts. """ class Prompt(Dialog): - persisted_options = "options"; - persisted_state = "state"; + persisted_options = "options" + persisted_state = "state" def __init__(self, dialog_id: str, validator: object = None): """Creates a new Prompt instance. Parameters @@ -32,7 +34,7 @@ def __init__(self, dialog_id: str, validator: object = None): """ super(Prompt, self).__init__(str) - self._validator = validator; + self._validator = validator async def begin_dialog(self, dc: DialogContext, options: object) -> DialogTurnResult: if not dc: @@ -44,15 +46,15 @@ async def begin_dialog(self, dc: DialogContext, options: object) -> DialogTurnRe options.prompt.input_hint = InputHints.expecting_input if options.RetryPrompt != None and not options.prompt.input_hint: - options.retry_prompt.input_hint = InputHints.expecting_input; + options.retry_prompt.input_hint = InputHints.expecting_input # Initialize prompt state - state = dc.active_dialog.state; - state[persisted_options] = options; - state[persisted_state] = Dict[str, object] + state = dc.active_dialog.state + state[self.persisted_options] = options + state[self.persisted_state] = Dict[str, object] # Send initial prompt - await on_prompt(dc.context, state[persisted_state], state[persisted_options], False) + await self.on_prompt(dc.context, state[self.persisted_state], state[self.persisted_options], False) return Dialog.end_of_turn @@ -62,19 +64,19 @@ async def continue_dialog(self, dc: DialogContext): # Don't do anything for non-message activities if dc.context.activity.type != ActivityTypes.message: - return Dialog.end_of_turn; + return Dialog.end_of_turn # Perform base recognition instance = dc.active_dialog - state = instance.state[persisted_state] - options = instance.State[persisted_options] - recognized = await on_recognize(dc.context, state, options) + state = instance.state[self.persisted_state] + options = instance.State[self.persisted_options] + recognized = await self.on_recognize(dc.context, state, options) # Validate the return value - is_valid = False; - if _validator != None: + is_valid = False + if self._validator != None: prompt_context = PromptValidatorContext(dc.Context, recognized, state, options) - is_valid = await _validator(promptContext) + is_valid = await self._validator(prompt_context) options.number_of_attempts += 1 else: if recognized.succeeded: @@ -84,8 +86,8 @@ async def continue_dialog(self, dc: DialogContext): return await dc.end_dialog(recognized.value) else: if not dc.context.responded: - await on_prompt(dc.context, state, options, true) - return Dialog.end_of_turn; + await on_prompt(dc.context, state, options, True) + return Dialog.end_of_turn async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: object) -> DialogTurnResult: # Prompts are typically leaf nodes on the stack but the dev is free to push other dialogs @@ -93,13 +95,13 @@ async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: o # dialog_resume() when the pushed on dialog ends. # To avoid the prompt prematurely ending we need to implement this method and # simply re-prompt the user. - await reprompt_dialog(dc.context, dc.active_dialog) + await self.reprompt_dialog(dc.context, dc.active_dialog) return Dialog.end_of_turn async def reprompt_dialog(self, turn_context: TurnContext, instance: DialogInstance): - state = instance.state[persisted_state] - options = instance.state[persisted_options] - await on_prompt(turn_context, state, options, False) + state = instance.state[self.persisted_state] + options = instance.state[self.persisted_options] + await self.on_prompt(turn_context, state, options, False) @abstractmethod async def on_prompt(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions, is_retry: bool): @@ -132,8 +134,8 @@ def hero_card() -> Activity: return None def list_style_none() -> Activity: activity = Activity() - activity.text = text; - return activity; + activity.text = text + return activity def default() -> Activity: # return ChoiceFactory.for_channel(channel_id, choices, text, None, options); return None @@ -151,19 +153,19 @@ def default() -> Activity: # Update prompt with text, actions and attachments if not prompt: # clone the prompt the set in the options (note ActivityEx has Properties so this is the safest mechanism) - prompt = copy(prompt); + prompt = copy.copy(prompt) - prompt.text = msg.text; + prompt.text = msg.text if (msg.suggested_actions != None and msg.suggested_actions.actions != None and len(msg.suggested_actions.actions) > 0): prompt.suggested_actions = msg.suggested_actions if msg.attachments != None and len(msg.attachments) > 0: - prompt.attachments = msg.attachments; + prompt.attachments = msg.attachments - return prompt; + return prompt else: # TODO: Update to InputHints.ExpectingInput; msg.input_hint = None - return msg; \ No newline at end of file + return msg \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py index c2220aa33..1a0fc6ddb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_validator_context.py @@ -26,7 +26,7 @@ def __init__(self, turn_context: TurnContext, recognized: PromptRecognizerResult Original set of options passed to the prompt by the calling dialog. """ - self._context = turn_context; + self._context = turn_context self._recognized = recognized self._state = state self._options = options diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py index 216d366fb..5cd9eb272 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py @@ -6,6 +6,7 @@ from botbuilder.schema import (ActivityTypes, Activity) from .datetime_resolution import DateTimeResolution from .prompt import Prompt +from .confirm_prompt import ConfirmPrompt from .prompt_options import PromptOptions from .prompt_recognizer_result import PromptRecognizerResult @@ -13,7 +14,7 @@ class TextPrompt(Prompt): # TODO: PromptValidator def __init__(self, dialog_id: str, validator: object): - super(ConfirmPrompt, self).__init__(dialog_id, validator) + super(TextPrompt, self).__init__(dialog_id, validator) async def on_prompt(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions, is_retry: bool): if not turn_context: @@ -38,4 +39,4 @@ async def on_recognize(self, turn_context: TurnContext, state: Dict[str, object] if message.text != None: result.succeeded = True result.value = message.text - return result; + return result diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index 42bda7043..858f52b4b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -3,7 +3,10 @@ import uuid -from typing import Dict +from typing import ( + Dict, + Coroutine, + List) from .dialog_reason import DialogReason from .dialog import Dialog from .dialog_turn_result import DialogTurnResult @@ -11,6 +14,7 @@ from .dialog_instance import DialogInstance from .waterfall_step_context import WaterfallStepContext from botbuilder.core import TurnContext +from botbuilder.schema import ActivityTypes from typing import Coroutine, List @@ -25,6 +29,8 @@ def __init__(self, dialog_id: str, steps: [Coroutine] = None): if not steps: self._steps = [] else: + if not isinstance(steps, list): + raise TypeError('WaterfallDialog(): steps must be list of steps') self._steps = steps # TODO: Add WaterfallStep class @@ -73,7 +79,7 @@ async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: return await self.resume_dialog(dc, DialogReason.ContinueCalled, dc.context.activity.text) async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: object): - if not dc: + if dc is None: raise TypeError('WaterfallDialog.resume_dialog(): dc cannot be None.') # Increment step index and run step @@ -82,7 +88,7 @@ async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: o # Future Me: # If issues with CosmosDB, see https://github.com/Microsoft/botbuilder-dotnet/issues/871 # for hints. - return self.run_step(dc, state[StepIndex] + 1, reason, result) + return self.run_step(dc, state[self.StepIndex] + 1, reason, result) async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, reason: DialogReason): # TODO: Add telemetry logging diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py index 1bff31c0f..465712c54 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_step_context.py @@ -26,7 +26,7 @@ def index(self) -> int: return self._index @property def options(self) -> object: - return self._options; + return self._options @property def reason(self)->DialogReason: return self._reason diff --git a/libraries/botbuilder-dialogs/tests/test_dialog_set.py b/libraries/botbuilder-dialogs/tests/test_dialog_set.py index d81e8fe34..e6d80be76 100644 --- a/libraries/botbuilder-dialogs/tests/test_dialog_set.py +++ b/libraries/botbuilder-dialogs/tests/test_dialog_set.py @@ -1,25 +1,20 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import aiounittest +from botbuilder.dialogs import ( DialogSet, ComponentDialog) +from botbuilder.core import ConversationState, MemoryStorage -import unittest -from botbuilder.core import BotAdapter -from botbuilder.dialogs import DialogSet -from botbuilder.core import MemoryStorage, ConversationState -from botbuilder.core.state_property_accessor import StatePropertyAccessor +class DialogSetTests(aiounittest.AsyncTestCase): + def test_dialogset_constructor_valid(self): + convo_state = ConversationState(MemoryStorage()) + dialog_state_property = convo_state.create_property("dialogstate") + ds = DialogSet(dialog_state_property) + assert ds is not None - -class DialogSetTests(unittest.TestCase): - def test_DialogSet_ConstructorValid(self): - storage = MemoryStorage(); - conv = ConversationState(storage) - accessor = conv.create_property("dialogstate") - ds = DialogSet(accessor); - self.assertNotEqual(ds, None) - - def test_DialogSet_ConstructorNoneProperty(self): + def test_dialogset_constructor_null_property(self): self.assertRaises(TypeError, lambda:DialogSet(None)) - - + def test_dialogset_constructor_null_from_componentdialog(self): + ComponentDialog("MyId") diff --git a/libraries/botbuilder-dialogs/tests/test_waterfall.py b/libraries/botbuilder-dialogs/tests/test_waterfall.py index cbe561932..bdcdb3806 100644 --- a/libraries/botbuilder-dialogs/tests/test_waterfall.py +++ b/libraries/botbuilder-dialogs/tests/test_waterfall.py @@ -13,6 +13,7 @@ TurnContext ) from botbuilder.dialogs import ( + Dialog, DialogSet, WaterfallDialog, WaterfallStepContext, @@ -52,47 +53,49 @@ def test_waterfall_none_name(self): def test_watterfall_add_none_step(self): waterfall = WaterfallDialog("test") self.assertRaises(TypeError, (lambda:waterfall.add_step(None))) - - async def notest_execute_sequence_waterfall_steps(self): - + async def test_waterfall_with_set_instead_of_array(self): + self.assertRaises(TypeError, lambda:WaterfallDialog('a', { 1, 2 })) + + # TODO:WORK IN PROGRESS + async def no_test_execute_sequence_waterfall_steps(self): # Create new ConversationState with MemoryStorage and register the state as middleware. convo_state = ConversationState(MemoryStorage()) # Create a DialogState property, DialogSet and register the WaterfallDialog. - dialog_state = convo_state.create_property('dialogState'); - dialogs = DialogSet(dialog_state); + dialog_state = convo_state.create_property('dialogState') + dialogs = DialogSet(dialog_state) async def step1(step) -> DialogTurnResult: assert(step, 'hey!') - await step.context.sendActivity('bot responding.') + await step.context.send_activity('bot responding.') return Dialog.end_of_turn async def step2(step) -> DialogTurnResult: assert(step) return await step.end_dialog('ending WaterfallDialog.') - mydialog = WaterfallDialog('a', { step1, step2 }) + mydialog = WaterfallDialog('test', [ step1, step2 ]) await dialogs.add(mydialog) # Initialize TestAdapter async def exec_test(turn_context: TurnContext) -> None: + dc = await dialogs.create_context(turn_context) results = await dc.continue_dialog(dc, None, None) if results.status == DialogTurnStatus.Empty: - await dc.begin_dialog('a') + await dc.begin_dialog('test') else: - if result.status == DialogTurnStatus.Complete: + if results.status == DialogTurnStatus.Complete: await turn_context.send_activity(results.result) await convo_state.save_changes(turn_context) adapt = TestAdapter(exec_test) - await adapt.send(begin_message) - await adapt.assert_reply('bot responding') - await adapt.send('continue') - await adapt.assert_reply('ending WaterfallDialog.') - - + tf = TestFlow(None, adapt) + tf2 = await tf.send(begin_message) + tf3 = await tf2.assert_reply('bot responding.') + tf4 = await tf3.send('continue') + tf5 = await tf4.assert_reply('ending WaterfallDialog.') async def test_waterfall_callback(self): convo_state = ConversationState(MemoryStorage()) From 384c873cfe784f7609501a1761f16037bdfcb687 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Mon, 22 Apr 2019 13:18:42 -0700 Subject: [PATCH 25/73] Move pytest to unittest (or aiounittest as the case may be) --- .../botbuilder-core/tests/test_bot_state.py | 23 ++++++-------- .../tests/test_conversation_state.py | 10 +++---- .../tests/test_memory_storage.py | 30 +++++++++---------- .../tests/test_message_factory.py | 2 +- .../tests/test_middleware_set.py | 28 ++++++++--------- .../tests/test_test_adapter.py | 28 ++++++++--------- .../tests/test_turn_context.py | 12 ++++---- .../botbuilder-core/tests/test_user_state.py | 10 +++---- 8 files changed, 69 insertions(+), 74 deletions(-) diff --git a/libraries/botbuilder-core/tests/test_bot_state.py b/libraries/botbuilder-core/tests/test_bot_state.py index 3ecba5c6d..0d4b967a8 100644 --- a/libraries/botbuilder-core/tests/test_bot_state.py +++ b/libraries/botbuilder-core/tests/test_bot_state.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import aiounittest -import pytest - -from botbuilder.core import TurnContext, BotState, MemoryStorage, TestAdapter +from botbuilder.core import TurnContext, BotState, MemoryStorage +from botbuilder.core.adapters import TestAdapter from botbuilder.schema import Activity RECEIVED_MESSAGE = Activity(type='message', @@ -21,18 +21,18 @@ def key_factory(context): return STORAGE_KEY -class TestBotState: +class TestBotState(aiounittest.AsyncTestCase): storage = MemoryStorage() adapter = TestAdapter() context = TurnContext(adapter, RECEIVED_MESSAGE) middleware = BotState(storage, key_factory) - @pytest.mark.asyncio + async def test_should_return_undefined_from_get_if_nothing_cached(self): state = await self.middleware.get(self.context) assert state is None, 'state returned' - @pytest.mark.asyncio + async def test_should_load_and_save_state_from_storage(self): async def next_middleware(): @@ -45,8 +45,6 @@ async def next_middleware(): assert STORAGE_KEY in items, 'saved state not found in storage.' assert items[STORAGE_KEY].test == 'foo', 'Missing test value in stored state.' - @pytest.mark.skipif(True, reason='skipping while goal of test is investigated, test currently fails') - @pytest.mark.asyncio async def test_should_force_read_of_state_from_storage(self): async def next_middleware(): state = cached_state(self.context, self.middleware.state_key) @@ -60,7 +58,7 @@ async def next_middleware(): await self.middleware.on_process_request(self.context, next_middleware) - @pytest.mark.asyncio + async def test_should_clear_state_storage(self): async def next_middleware(): @@ -73,7 +71,6 @@ async def next_middleware(): items = await self.storage.read([STORAGE_KEY]) assert not hasattr(items[STORAGE_KEY], 'test'), 'state not cleared from storage.' - @pytest.mark.asyncio async def test_should_force_immediate_write_of_state_to_storage(self): async def next_middleware(): state = cached_state(self.context, self.middleware.state_key) @@ -85,23 +82,21 @@ async def next_middleware(): assert items[STORAGE_KEY].test == 'foo', 'state not immediately flushed.' await self.middleware.on_process_request(self.context, next_middleware) - @pytest.mark.asyncio async def test_should_read_from_storage_if_cached_state_missing(self): self.context.services[self.middleware.state_key] = None state = await self.middleware.read(self.context) assert state.test == 'foo', 'state not loaded' - @pytest.mark.asyncio async def test_should_read_from_cache(self): state = await self.middleware.read(self.context) assert state.test == 'foo', 'state not loaded' - @pytest.mark.asyncio + async def test_should_force_write_to_storage_of_an_empty_state_object(self): self.context.services[self.middleware.state_key] = None await self.middleware.write(self.context, True) - @pytest.mark.asyncio + async def test_should_noop_calls_to_clear_when_nothing_cached(self): self.context.services[self.middleware.state_key] = None await self.middleware.clear(self.context) diff --git a/libraries/botbuilder-core/tests/test_conversation_state.py b/libraries/botbuilder-core/tests/test_conversation_state.py index 0be80f1ba..c4fac84b7 100644 --- a/libraries/botbuilder-core/tests/test_conversation_state.py +++ b/libraries/botbuilder-core/tests/test_conversation_state.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import pytest +import aiounittest from botbuilder.core import TurnContext, MemoryStorage, TestAdapter, ConversationState from botbuilder.schema import Activity, ConversationAccount @@ -27,7 +27,7 @@ class TestConversationState: context = TurnContext(adapter, RECEIVED_MESSAGE) middleware = ConversationState(storage) - @pytest.mark.asyncio + async def test_should_load_and_save_state_from_storage(self): key = None @@ -45,7 +45,7 @@ async def next_middleware(): assert key in items, 'Saved state not found in storage.' assert items[key].test == 'foo', 'Missing test value in stored state.' - @pytest.mark.asyncio + async def test_should_ignore_any_activities_that_are_not_endOfConversation(self): key = None @@ -60,7 +60,7 @@ async def next_middleware(): items = await self.storage.read([key]) assert hasattr(items[key], 'test'), 'state cleared and should not have been' - @pytest.mark.asyncio + async def test_should_reject_with_error_if_channel_id_is_missing(self): context = TurnContext(self.adapter, MISSING_CHANNEL_ID) @@ -76,7 +76,7 @@ async def next_middleware(): else: raise AssertionError('Should not have completed and not raised AttributeError.') - @pytest.mark.asyncio + async def test_should_reject_with_error_if_conversation_is_missing(self): context = TurnContext(self.adapter, MISSING_CONVERSATION) diff --git a/libraries/botbuilder-core/tests/test_memory_storage.py b/libraries/botbuilder-core/tests/test_memory_storage.py index b565d57e5..7185fdc4d 100644 --- a/libraries/botbuilder-core/tests/test_memory_storage.py +++ b/libraries/botbuilder-core/tests/test_memory_storage.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import pytest +import aiounittest from botbuilder.core import MemoryStorage, StoreItem @@ -13,7 +13,7 @@ def __init__(self, counter=1, e_tag='0'): self.e_tag = e_tag -class TestMemoryStorage: +class TestMemoryStorage(aiounittest.AsyncTestCase): def test_initializing_memory_storage_without_data_should_still_have_memory(self): storage = MemoryStorage() assert storage.memory is not None @@ -23,7 +23,7 @@ def test_memory_storage__e_tag_should_start_at_0(self): storage = MemoryStorage() assert storage._e_tag == 0 - @pytest.mark.asyncio + async def test_memory_storage_initialized_with_memory_should_have_accessible_data(self): storage = MemoryStorage({'test': SimpleStoreItem()}) data = await storage.read(['test']) @@ -31,7 +31,7 @@ async def test_memory_storage_initialized_with_memory_should_have_accessible_dat assert data['test'].counter == 1 assert len(data.keys()) == 1 - @pytest.mark.asyncio + async def test_memory_storage_read_should_return_data_with_valid_key(self): storage = MemoryStorage() await storage.write({'user': SimpleStoreItem()}) @@ -43,7 +43,7 @@ async def test_memory_storage_read_should_return_data_with_valid_key(self): assert storage._e_tag == 1 assert int(data['user'].e_tag) == 1 - @pytest.mark.asyncio + async def test_memory_storage_write_should_add_new_value(self): storage = MemoryStorage() await storage.write({'user': SimpleStoreItem(counter=1)}) @@ -52,7 +52,7 @@ async def test_memory_storage_write_should_add_new_value(self): assert 'user' in data assert data['user'].counter == 1 - @pytest.mark.asyncio + async def test_memory_storage_write_should_overwrite_cached_value_with_valid_newer_e_tag(self): storage = MemoryStorage() await storage.write({'user': SimpleStoreItem()}) @@ -66,7 +66,7 @@ async def test_memory_storage_write_should_overwrite_cached_value_with_valid_new except Exception as e: raise e - @pytest.mark.asyncio + async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk(self): storage = MemoryStorage() await storage.write({'user': SimpleStoreItem(e_tag='1')}) @@ -75,7 +75,7 @@ async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asteri data = await storage.read(['user']) assert data['user'].counter == 10 - @pytest.mark.asyncio + async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk(self): storage = MemoryStorage() await storage.write({'user': SimpleStoreItem(e_tag='1')}) @@ -84,7 +84,7 @@ async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asteri data = await storage.read(['user']) assert data['user'].counter == 5 - @pytest.mark.asyncio + async def test_memory_storage_write_should_raise_a_key_error_with_older_e_tag(self): storage = MemoryStorage() await storage.write({'user': SimpleStoreItem(e_tag='1')}) @@ -99,7 +99,7 @@ async def test_memory_storage_write_should_raise_a_key_error_with_older_e_tag(se raise AssertionError("test_memory_storage_read_should_raise_a_key_error_with_invalid_e_tag(): should have " "raised a KeyError with an invalid e_tag.") - @pytest.mark.asyncio + async def test_memory_storage_read_with_invalid_key_should_return_empty_dict(self): storage = MemoryStorage() data = await storage.read(['test']) @@ -107,7 +107,7 @@ async def test_memory_storage_read_with_invalid_key_should_return_empty_dict(sel assert type(data) == dict assert len(data.keys()) == 0 - @pytest.mark.asyncio + async def test_memory_storage_delete_should_delete_according_cached_data(self): storage = MemoryStorage({'test': 'test'}) try: @@ -120,7 +120,7 @@ async def test_memory_storage_delete_should_delete_according_cached_data(self): assert type(data) == dict assert len(data.keys()) == 0 - @pytest.mark.asyncio + async def test_memory_storage_delete_should_delete_multiple_values_when_given_multiple_valid_keys(self): storage = MemoryStorage({'test': SimpleStoreItem(), 'test2': SimpleStoreItem(2, '2')}) @@ -128,7 +128,7 @@ async def test_memory_storage_delete_should_delete_multiple_values_when_given_mu data = await storage.read(['test', 'test2']) assert len(data.keys()) == 0 - @pytest.mark.asyncio + async def test_memory_storage_delete_should_delete_values_when_given_multiple_valid_keys_and_ignore_other_data(self): storage = MemoryStorage({'test': SimpleStoreItem(), 'test2': SimpleStoreItem(2, '2'), @@ -138,7 +138,7 @@ async def test_memory_storage_delete_should_delete_values_when_given_multiple_va data = await storage.read(['test', 'test2', 'test3']) assert len(data.keys()) == 1 - @pytest.mark.asyncio + async def test_memory_storage_delete_invalid_key_should_do_nothing_and_not_affect_cached_data(self): storage = MemoryStorage({'test': 'test'}) @@ -148,7 +148,7 @@ async def test_memory_storage_delete_invalid_key_should_do_nothing_and_not_affec data = await storage.read(['foo']) assert len(data.keys()) == 0 - @pytest.mark.asyncio + async def test_memory_storage_delete_invalid_keys_should_do_nothing_and_not_affect_cached_data(self): storage = MemoryStorage({'test': 'test'}) diff --git a/libraries/botbuilder-core/tests/test_message_factory.py b/libraries/botbuilder-core/tests/test_message_factory.py index 6e17dc454..83d6dfcab 100644 --- a/libraries/botbuilder-core/tests/test_message_factory.py +++ b/libraries/botbuilder-core/tests/test_message_factory.py @@ -35,7 +35,7 @@ def assert_attachments(activity: Activity, count: int, types: List[str] = None): assert attachment.content_type == types[idx], f'attachment[{idx}] has invalid content_type' -class TestMessageFactory: +class TestMessageFactory(aiounittest.AsyncTestCase): suggested_actions = [CardAction(title='a', type=ActionTypes.im_back, value='a'), CardAction(title='b', type=ActionTypes.im_back, value='b'), diff --git a/libraries/botbuilder-core/tests/test_middleware_set.py b/libraries/botbuilder-core/tests/test_middleware_set.py index a81f02fb8..678d4e6c0 100644 --- a/libraries/botbuilder-core/tests/test_middleware_set.py +++ b/libraries/botbuilder-core/tests/test_middleware_set.py @@ -1,21 +1,21 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import pytest +import aiounittest from botbuilder.core import AnonymousReceiveMiddleware, MiddlewareSet, Middleware -class TestMiddlewareSet: +class TestMiddlewareSet(aiounittest.AsyncTestCase): - @pytest.mark.asyncio + async def test_no_middleware(self): middleware_set = MiddlewareSet() # This shouldn't explode. await middleware_set.receive_activity(None) - @pytest.mark.asyncio + async def test_no_middleware_with_callback(self): callback_complete = False @@ -28,7 +28,7 @@ async def runs_after_pipeline(context): await middleware_set.receive_activity_with_status(None, runs_after_pipeline) assert callback_complete - @pytest.mark.asyncio + async def test_middleware_set_receive_activity_internal(self): class PrintMiddleware(object): @@ -57,7 +57,7 @@ async def request_handler(context_or_string): await middleware_set.receive_activity_internal('Bye', request_handler) - @pytest.mark.asyncio + async def test_middleware_run_in_order(self): called_first = False called_second = False @@ -84,7 +84,7 @@ async def on_process_request(self, context, logic): assert called_first assert called_second - @pytest.mark.asyncio + async def test_run_one_middleware(self): called_first = False finished_pipeline = False @@ -106,7 +106,7 @@ async def runs_after_pipeline(context): assert called_first assert finished_pipeline - @pytest.mark.asyncio + async def test_run_empty_pipeline(self): ran_empty_pipeline = False middleware_set = MiddlewareSet() @@ -118,7 +118,7 @@ async def runs_after_pipeline(context): await middleware_set.receive_activity_with_status(None, runs_after_pipeline) assert ran_empty_pipeline - @pytest.mark.asyncio + async def test_two_middleware_one_does_not_call_next(self): called_first = False called_second = False @@ -147,7 +147,7 @@ async def on_process_request(self, context, logic): assert not called_second assert not called_all_middleware - @pytest.mark.asyncio + async def test_one_middleware_does_not_call_next(self): called_first = False finished_pipeline = False @@ -169,7 +169,7 @@ async def runs_after_pipeline(context): assert called_first assert not finished_pipeline - @pytest.mark.asyncio + async def test_anonymous_middleware(self): did_run = False @@ -186,7 +186,7 @@ async def processor(context, logic): await middleware_set.receive_activity(None) assert did_run - @pytest.mark.asyncio + async def test_anonymous_two_middleware_and_in_order(self): called_first = False called_second = False @@ -211,7 +211,7 @@ async def processor_two(context, logic): assert called_first assert called_second - @pytest.mark.asyncio + async def test_mixed_middleware_anonymous_first(self): called_regular_middleware = False called_anonymous_middleware = False @@ -238,7 +238,7 @@ async def anonymous_method(context, logic): assert called_regular_middleware assert called_anonymous_middleware - @pytest.mark.asyncio + async def test_mixed_middleware_anonymous_last(self): called_regular_middleware = False called_anonymous_middleware = False diff --git a/libraries/botbuilder-core/tests/test_test_adapter.py b/libraries/botbuilder-core/tests/test_test_adapter.py index 3a94cf014..9785fd93f 100644 --- a/libraries/botbuilder-core/tests/test_test_adapter.py +++ b/libraries/botbuilder-core/tests/test_test_adapter.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import pytest +import aiounittest from botbuilder.schema import Activity, ConversationReference from botbuilder.core import TurnContext, TestAdapter from datetime import datetime @@ -11,8 +11,8 @@ DELETED_ACTIVITY_REFERENCE = ConversationReference(activity_id='1234') -class TestTestAdapter: - @pytest.mark.asyncio +class TestTestAdapter(aiounittest.AsyncTestCase): + async def test_should_call_bog_logic_when_receive_activity_is_called(self): async def logic(context: TurnContext): assert context @@ -28,7 +28,7 @@ async def logic(context: TurnContext): adapter = TestAdapter(logic) await adapter.receive_activity('test') - @pytest.mark.asyncio + async def test_should_support_receive_activity_with_activity(self): async def logic(context: TurnContext): assert context.activity.type == 'message' @@ -36,7 +36,7 @@ async def logic(context: TurnContext): adapter = TestAdapter(logic) await adapter.receive_activity(Activity(type='message', text='test')) - @pytest.mark.asyncio + async def test_should_set_activity_type_when_receive_activity_receives_activity_without_type(self): async def logic(context: TurnContext): assert context.activity.type == 'message' @@ -44,7 +44,7 @@ async def logic(context: TurnContext): adapter = TestAdapter(logic) await adapter.receive_activity(Activity(text='test')) - @pytest.mark.asyncio + async def test_should_support_custom_activity_id_in_receive_activity(self): async def logic(context: TurnContext): assert context.activity.id == 'myId' @@ -53,21 +53,21 @@ async def logic(context: TurnContext): adapter = TestAdapter(logic) await adapter.receive_activity(Activity(type='message', text='test', id='myId')) - @pytest.mark.asyncio + async def test_should_call_bot_logic_when_send_is_called(self): async def logic(context: TurnContext): assert context.activity.text == 'test' adapter = TestAdapter(logic) await adapter.send('test') - @pytest.mark.asyncio + async def test_should_send_and_receive_when_test_is_called(self): async def logic(context: TurnContext): await context.send_activity(RECEIVED_MESSAGE) adapter = TestAdapter(logic) await adapter.test('test', 'received') - @pytest.mark.asyncio + async def test_should_send_and_throw_assertion_error_when_test_is_called(self): async def logic(context: TurnContext): await context.send_activity(RECEIVED_MESSAGE) @@ -79,7 +79,7 @@ async def logic(context: TurnContext): else: raise AssertionError('Assertion error should have been raised') - @pytest.mark.asyncio + async def test_tests_should_call_test_for_each_tuple(self): counter = 0 @@ -92,7 +92,7 @@ async def logic(context: TurnContext): await adapter.tests(('test', '1'), ('test', '2'), ('test', '3')) assert counter == 3 - @pytest.mark.asyncio + async def test_tests_should_call_test_for_each_list(self): counter = 0 @@ -105,7 +105,7 @@ async def logic(context: TurnContext): await adapter.tests(['test', '1'], ['test', '2'], ['test', '3']) assert counter == 3 - @pytest.mark.asyncio + async def test_should_assert_reply_after_send(self): async def logic(context: TurnContext): await context.send_activity(RECEIVED_MESSAGE) @@ -114,7 +114,7 @@ async def logic(context: TurnContext): test_flow = await adapter.send('test') await test_flow.assert_reply('received') - @pytest.mark.asyncio + async def test_should_support_context_update_activity_call(self): async def logic(context: TurnContext): await context.update_activity(UPDATED_ACTIVITY) @@ -125,7 +125,7 @@ async def logic(context: TurnContext): assert len(adapter.updated_activities) == 1 assert adapter.updated_activities[0].text == UPDATED_ACTIVITY.text - @pytest.mark.asyncio + async def test_should_support_context_delete_activity_call(self): async def logic(context: TurnContext): await context.delete_activity(DELETED_ACTIVITY_REFERENCE) diff --git a/libraries/botbuilder-core/tests/test_turn_context.py b/libraries/botbuilder-core/tests/test_turn_context.py index 7d9d6987b..77afe71e6 100644 --- a/libraries/botbuilder-core/tests/test_turn_context.py +++ b/libraries/botbuilder-core/tests/test_turn_context.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import pytest +import aiounittest from botbuilder.schema import Activity, ChannelAccount, ResourceResponse, ConversationAccount from botbuilder.core import BotAdapter, TurnContext @@ -39,7 +39,7 @@ async def delete_activity(self, context, reference): assert reference.activity_id == '1234' -class TestBotContext: +class TestBotContext(aiounittest.AsyncTestCase): def test_should_create_context_with_request_and_adapter(self): context = TurnContext(SimpleAdapter(), ACTIVITY) @@ -125,7 +125,7 @@ def test_should_not_be_able_to_set_responded_to_False(self): except Exception as e: raise e - @pytest.mark.asyncio + async def test_should_call_on_delete_activity_handlers_before_deletion(self): context = TurnContext(SimpleAdapter(), ACTIVITY) called = False @@ -142,7 +142,7 @@ async def delete_handler(context, reference, next_handler_coroutine): await context.delete_activity(ACTIVITY.id) assert called is True - @pytest.mark.asyncio + async def test_should_call_multiple_on_delete_activity_handlers_in_order(self): context = TurnContext(SimpleAdapter(), ACTIVITY) called_first = False @@ -174,7 +174,7 @@ async def second_delete_handler(context, reference, next_handler_coroutine): assert called_first is True assert called_second is True - @pytest.mark.asyncio + async def test_should_call_send_on_activities_handler_before_send(self): context = TurnContext(SimpleAdapter(), ACTIVITY) called = False @@ -191,7 +191,7 @@ async def send_handler(context, activities, next_handler_coroutine): await context.send_activity(ACTIVITY) assert called is True - @pytest.mark.asyncio + async def test_should_call_on_update_activity_handler_before_update(self): context = TurnContext(SimpleAdapter(), ACTIVITY) called = False diff --git a/libraries/botbuilder-core/tests/test_user_state.py b/libraries/botbuilder-core/tests/test_user_state.py index 335bced2b..bac1fc357 100644 --- a/libraries/botbuilder-core/tests/test_user_state.py +++ b/libraries/botbuilder-core/tests/test_user_state.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import pytest +import aiounittest from botbuilder.core import TurnContext, MemoryStorage, StoreItem, TestAdapter, UserState from botbuilder.schema import Activity, ChannelAccount @@ -18,13 +18,13 @@ channel_id='test') -class TestUserState: +class TestUserState(aiounittest.AsyncTestCase): storage = MemoryStorage() adapter = TestAdapter() context = TurnContext(adapter, RECEIVED_MESSAGE) middleware = UserState(storage) - @pytest.mark.asyncio + async def test_should_load_and_save_state_from_storage(self): async def next_middleware(): @@ -39,7 +39,7 @@ async def next_middleware(): assert key in items, 'Saved state not found in storage' assert items[key].test == 'foo', 'Missing test value in stored state.' - @pytest.mark.asyncio + async def test_should_reject_with_error_if_channel_id_is_missing(self): context = TurnContext(self.adapter, MISSING_CHANNEL_ID) @@ -53,7 +53,7 @@ async def next_middleware(): else: raise AssertionError('Should not have completed and not raised AttributeError.') - @pytest.mark.asyncio + async def test_should_reject_with_error_if_from_property_is_missing(self): context = TurnContext(self.adapter, MISSING_FROM_PROPERTY) From 61b56dee6affd1ecaec1ac60964e34b8b53e4c9b Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Mon, 22 Apr 2019 13:25:39 -0700 Subject: [PATCH 26/73] Small fixes --- .../botbuilder/core/adapters/test_adapter.py | 22 +++++++++++-------- .../botbuilder/core/conversation_state.py | 8 +++---- .../botbuilder/core/user_state.py | 4 ++-- .../botbuilder/dialogs/prompts/prompt.py | 2 +- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index 144415655..fabe092b5 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -7,7 +7,7 @@ from typing import Coroutine, List from copy import copy from ..bot_adapter import BotAdapter -from ..turn_contect import TurnContext +from ..turn_context import TurnContext from botbuilder.schema import (ActivityTypes, Activity, ConversationAccount, ConversationReference, ChannelAccount, ResourceResponse) @@ -33,10 +33,10 @@ def __init__(self, logic: Coroutine=None, conversation: ConversationReference=No recipient=ChannelAccount(id='bot', name='Bot'), conversation=ConversationAccount(id='Convo1') ) - if template is not None: - self.template.service_url = template.service_url - self.template.conversation = template.conversation - self.template.channel_id = template.channel_id + if self.template is not None: + self.template.service_url = self.template.service_url + self.template.conversation = self.template.conversation + self.template.channel_id = self.template.channel_id async def send_activities(self, context, activities: List[Activity]): """ @@ -199,12 +199,16 @@ async def assert_reply(self, expected, description=None, timeout=None) -> 'TestF :param timeout: :return: """ - + print('ASSERT REPLY') def default_inspector(reply, description=None): - + print('HELLOOOO FROM THE INSPECTOR...........') if isinstance(expected, Activity): validate_activity(reply, expected) else: + print('EXPECTED') + print(expected) + #print('REPLYTEXT') + #print(reply.text) assert reply.type == 'message', description + f" type == {reply.type}" assert reply.text == expected, description + f" text == {reply.text}" @@ -238,9 +242,9 @@ async def wait_for_activity(): else: await asyncio.sleep(0.05) await wait_for_activity() - + print('IN WAIT FOR PREVIOUS BEFORE WAIT FOR ACTIVITY') await wait_for_activity() - + print('IN ASSERTREPLY BEFORE invoking new TESTFLOW') return TestFlow(await test_flow_previous(), self.adapter) diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index f2c37dc81..967061f0f 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -13,7 +13,7 @@ class ConversationState(BotState): no_key_error_message = 'ConversationState: channelId and/or conversation missing from context.activity.' - def __init__(self, storage: Storage, namespace: str=''): + def __init__(self, storage: Storage): """Creates a new ConversationState instance. Parameters ---------- @@ -28,8 +28,8 @@ def call_get_storage_key(context): else: return key - super(ConversationState, self).__init__(storage, call_get_storage_key) - self.namespace = namespace + super(ConversationState, self).__init__(storage, 'ConversationState') + def get_storage_key(self, context: TurnContext): activity = context.activity @@ -38,5 +38,5 @@ def get_storage_key(self, context: TurnContext): storage_key = None if channel_id and conversation_id: - storage_key = f"conversation/{channel_id}/{conversation_id}/{self.namespace}" + storage_key = "%s/conversations/%s" % (channel_id,conversation_id) return storage_key diff --git a/libraries/botbuilder-core/botbuilder/core/user_state.py b/libraries/botbuilder-core/botbuilder/core/user_state.py index 9bc0d1258..28aa8c660 100644 --- a/libraries/botbuilder-core/botbuilder/core/user_state.py +++ b/libraries/botbuilder-core/botbuilder/core/user_state.py @@ -28,7 +28,7 @@ def call_get_storage_key(context): else: return key - super(UserState, self).__init__(storage, call_get_storage_key) + super(UserState, self).__init__(storage, "UserState") def get_storage_key(self, context: TurnContext) -> str: """ @@ -42,5 +42,5 @@ def get_storage_key(self, context: TurnContext) -> str: storage_key = None if channel_id and user_id: - storage_key = f"user/{channel_id}/{user_id}/{self.namespace}" + storage_key = "%s/users/%s" % (channel_id, user_id) return storage_key diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py index 569b799e3..0c8506b0e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py @@ -86,7 +86,7 @@ async def continue_dialog(self, dc: DialogContext): return await dc.end_dialog(recognized.value) else: if not dc.context.responded: - await on_prompt(dc.context, state, options, True) + await self.on_prompt(dc.context, state, options, True) return Dialog.end_of_turn async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: object) -> DialogTurnResult: From bd8344d9fe19bea3792b0edc01664ba1f1042129 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 23 Apr 2019 00:20:12 -0700 Subject: [PATCH 27/73] few tests for bot state --- .../botbuilder/core/bot_state.py | 3 +- libraries/botbuilder-core/tests/__init__.py | 10 ++ .../botbuilder-core/tests/test_bot_state.py | 127 ++++++++---------- .../botbuilder-core/tests/test_utilities.py | 18 +++ 4 files changed, 86 insertions(+), 72 deletions(-) create mode 100644 libraries/botbuilder-core/tests/__init__.py create mode 100644 libraries/botbuilder-core/tests/test_utilities.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 8f4677e3a..47d73b3e3 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -58,7 +58,7 @@ def create_property(self, name:str) -> StatePropertyAccessor: :return: If successful, the state property accessor created. """ if not name: - raise TypeError('BotState.create_property(): BotState cannot be None.') + raise TypeError('BotState.create_property(): BotState cannot be None or empty.') return BotStatePropertyAccessor(self, name) @@ -172,6 +172,7 @@ async def set_property_value(self, turn_context: TurnContext, property_name: str cached_state = turn_context.turn_state.get(self._context_service_key) cached_state.state[property_name] = value +## class BotStatePropertyAccessor(StatePropertyAccessor): def __init__(self, bot_state: BotState, name: str): self._bot_state = bot_state diff --git a/libraries/botbuilder-core/tests/__init__.py b/libraries/botbuilder-core/tests/__init__.py new file mode 100644 index 000000000..2c90d1f71 --- /dev/null +++ b/libraries/botbuilder-core/tests/__init__.py @@ -0,0 +1,10 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .test_utilities import TestUtilities + +__all__ = ['TestUtilities'] diff --git a/libraries/botbuilder-core/tests/test_bot_state.py b/libraries/botbuilder-core/tests/test_bot_state.py index 0d4b967a8..07bcdf0c5 100644 --- a/libraries/botbuilder-core/tests/test_bot_state.py +++ b/libraries/botbuilder-core/tests/test_bot_state.py @@ -1,11 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import aiounittest +from unittest.mock import MagicMock -from botbuilder.core import TurnContext, BotState, MemoryStorage +from botbuilder.core import TurnContext, BotState, MemoryStorage, UserState from botbuilder.core.adapters import TestAdapter from botbuilder.schema import Activity +from .test_utilities import TestUtilities + RECEIVED_MESSAGE = Activity(type='message', text='received') STORAGE_KEY = 'stateKey' @@ -28,75 +31,57 @@ class TestBotState(aiounittest.AsyncTestCase): middleware = BotState(storage, key_factory) - async def test_should_return_undefined_from_get_if_nothing_cached(self): - state = await self.middleware.get(self.context) - assert state is None, 'state returned' - + def test_state_empty_name(self): + #Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + + #Act + with self.assertRaises(TypeError) as _: + user_state.create_property('') - async def test_should_load_and_save_state_from_storage(self): - - async def next_middleware(): - state = cached_state(self.context, self.middleware.state_key) - assert state is not None, 'state not loaded' - state.test = 'foo' - - await self.middleware.on_process_request(self.context, next_middleware) - items = await self.storage.read([STORAGE_KEY]) - assert STORAGE_KEY in items, 'saved state not found in storage.' - assert items[STORAGE_KEY].test == 'foo', 'Missing test value in stored state.' - - async def test_should_force_read_of_state_from_storage(self): - async def next_middleware(): - state = cached_state(self.context, self.middleware.state_key) - assert state.test == 'foo', 'invalid initial state' - del state.test - - # items will not have the attribute 'test' - items = await self.middleware.read(self.context, True) - # Similarly, the returned value from cached_state will also not have the attribute 'test' - assert cached_state(self.context, self.middleware.state_key).test == 'foo', 'state not reloaded' - - await self.middleware.on_process_request(self.context, next_middleware) - - - async def test_should_clear_state_storage(self): - - async def next_middleware(): - assert cached_state(self.context, self.middleware.state_key).test == 'foo', 'invalid initial state' - await self.middleware.clear(self.context) - cached_state_data = cached_state(self.context, self.middleware.state_key) - assert not hasattr(cached_state_data, 'test'), 'state not cleared on context.' - - await self.middleware.on_process_request(self.context, next_middleware) - items = await self.storage.read([STORAGE_KEY]) - assert not hasattr(items[STORAGE_KEY], 'test'), 'state not cleared from storage.' - - async def test_should_force_immediate_write_of_state_to_storage(self): - async def next_middleware(): - state = cached_state(self.context, self.middleware.state_key) - assert not hasattr(state, 'test'), 'invalid initial state' - state.test = 'foo' - - await self.middleware.write(self.context, True) - items = await self.storage.read([STORAGE_KEY]) - assert items[STORAGE_KEY].test == 'foo', 'state not immediately flushed.' - await self.middleware.on_process_request(self.context, next_middleware) - - async def test_should_read_from_storage_if_cached_state_missing(self): - self.context.services[self.middleware.state_key] = None - state = await self.middleware.read(self.context) - assert state.test == 'foo', 'state not loaded' - - async def test_should_read_from_cache(self): - state = await self.middleware.read(self.context) - assert state.test == 'foo', 'state not loaded' - - - async def test_should_force_write_to_storage_of_an_empty_state_object(self): - self.context.services[self.middleware.state_key] = None - await self.middleware.write(self.context, True) - + def test_state_none_name(self): + #Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + + #Act + with self.assertRaises(TypeError) as _: + user_state.create_property(None) - async def test_should_noop_calls_to_clear_when_nothing_cached(self): - self.context.services[self.middleware.state_key] = None - await self.middleware.clear(self.context) + async def test_storage_not_called_no_changes(self): + """Verify storage not called when no changes are made""" + # Mock a storage provider, which counts read/writes + dictionary = {} + mock_storage = MemoryStorage(dictionary) + mock_storage.write = MagicMock(return_value= 1) + mock_storage.read = MagicMock(return_value= 1) + + # Arrange + user_state = UserState(mock_storage) + context = TestUtilities.create_empty_context() + + # Act + propertyA = user_state.create_property("propertyA") + self.assertEqual(mock_storage.write.call_count, 0) + await user_state.save_changes(context) + await propertyA.set(context, "hello") + self.assertEqual(mock_storage.read.call_count, 1) # Initial save bumps count + self.assertEqual(mock_storage.write.call_count, 0) # Initial save bumps count + await propertyA.set(context, "there") + self.assertEqual(mock_storage.write.call_count, 0) # Set on property should not bump + await user_state.save_changes(context) + self.assertEqual(mock_storage.write.call_count, 1) # Explicit save should bump + valueA = await propertyA.get(context) + self.assertEqual("there", valueA) + self.assertEqual(mock_storage.write.call_count, 1) # Gets should not bump + await user_state.save_changes(context) + self.assertEqual(mock_storage.write.call_count, 1) + await propertyA.DeleteAsync(context) # Delete alone no bump + self.assertEqual(mock_storage.write.call_count, 1) + await user_state.save_changes(context) # Save when dirty should bump + self.assertEqual(mock_storage.write.call_count, 2) + self.assertEqual(mock_storage.read.call_count, 1) + await user_state.save_changes(context) # Save not dirty should not bump + self.assertEqual(mock_storage.write.call_count, 2) + self.assertEqual(mock_storage.read.call_count, 1) diff --git a/libraries/botbuilder-core/tests/test_utilities.py b/libraries/botbuilder-core/tests/test_utilities.py new file mode 100644 index 000000000..6ca122a26 --- /dev/null +++ b/libraries/botbuilder-core/tests/test_utilities.py @@ -0,0 +1,18 @@ +from botbuilder.schema import Activity, ActivityTypes, ConversationAccount, ChannelAccount +from botbuilder.core import TurnContext +from botbuilder.core.adapters import TestAdapter + +class TestUtilities: + + @staticmethod + def create_empty_context(): + b = TestAdapter() + a = Activity( + type = ActivityTypes.message, + channel_id = "EmptyContext", + conversation = ConversationAccount(id= 'test' ), + from_property = ChannelAccount(id= 'empty@empty.context.org') + ) + bc = TurnContext(b, a) + + return bc \ No newline at end of file From 7cc45e227705cb4e21751a6e2b8e4268b8e46846 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Tue, 23 Apr 2019 11:13:45 -0700 Subject: [PATCH 28/73] Safekeeping checkin --- .../botbuilder-applicationinsights/README.rst | 91 ++++++++++++++ .../applicationinsights/__init__.py | 10 ++ .../botbuilder/applicationinsights/about.py | 12 ++ .../application_insights_telemetry_client.py | 113 ++++++++++++++++++ .../requirements.txt | 3 + .../botbuilder-applicationinsights/setup.cfg | 2 + .../botbuilder-applicationinsights/setup.py | 40 +++++++ .../tests/test_telemetry_waterfall.py | 77 ++++++++++++ .../botbuilder/core/__init__.py | 3 + .../botbuilder/core/adapters/test_adapter.py | 8 -- .../botbuilder/core/bot_state.py | 15 ++- .../botbuilder/dialogs/dialog.py | 24 +++- .../botbuilder/dialogs/dialog_context.py | 4 +- .../botbuilder/dialogs/dialog_instance.py | 9 +- .../botbuilder/dialogs/waterfall_dialog.py | 61 +++++++--- .../tests/test_waterfall.py | 14 ++- 16 files changed, 449 insertions(+), 37 deletions(-) create mode 100644 libraries/botbuilder-applicationinsights/README.rst create mode 100644 libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/__init__.py create mode 100644 libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py create mode 100644 libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py create mode 100644 libraries/botbuilder-applicationinsights/requirements.txt create mode 100644 libraries/botbuilder-applicationinsights/setup.cfg create mode 100644 libraries/botbuilder-applicationinsights/setup.py create mode 100644 libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py diff --git a/libraries/botbuilder-applicationinsights/README.rst b/libraries/botbuilder-applicationinsights/README.rst new file mode 100644 index 000000000..9150398fb --- /dev/null +++ b/libraries/botbuilder-applicationinsights/README.rst @@ -0,0 +1,91 @@ + +============================================= +BotBuilder-ApplicationInsights SDK for Python +============================================= + +.. image:: https://travis-ci.org/Microsoft/botbuilder-python.svg?branch=master + :target: https://travis-ci.org/Microsoft/botbuilder-python + :align: right + :alt: Travis status for master branch +.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/SDK_v4-Python-package-CI?branchName=master + :target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/SDK_v4-Python-package-CI + :align: right + :alt: Azure DevOps status for master branch +.. image:: https://badge.fury.io/py/botbuilder-core.svg + :target: https://badge.fury.io/py/botbuilder-core + :alt: Latest PyPI package version + +Within the Bot Framework, BotBuilder-ApplicationInsights enables the Azure Application Insights service. + +Application Insights is an extensible Application Performance Management (APM) service for developers on multiple platforms. +Use it to monitor your live bot application. It includes powerful analytics tools to help you diagnose issues and to understand +what users actually do with your bot. + +How to Install +============== + +.. code-block:: python + + pip install botbuilder-applicationinsights + + +Documentation/Wiki +================== + +You can find more information on the botbuilder-python project by visiting our `Wiki`_. + +Requirements +============ + +* `Python >= 3.6.8`_ + + +Source Code +=========== +The latest developer version is available in a github repository: +https://github.com/Microsoft/botbuilder-python/ + + +Contributing +============ + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the `Microsoft Open Source Code of Conduct`_. +For more information see the `Code of Conduct FAQ`_ or +contact `opencode@microsoft.com`_ with any additional questions or comments. + +Reporting Security Issues +========================= + +Security issues and bugs should be reported privately, via email, to the Microsoft Security +Response Center (MSRC) at `secure@microsoft.com`_. You should +receive a response within 24 hours. If for some reason you do not, please follow up via +email to ensure we received your original message. Further information, including the +`MSRC PGP`_ key, can be found in +the `Security TechCenter`_. + +License +======= + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT_ License. + +.. _Wiki: https://github.com/Microsoft/botbuilder-python/wiki +.. _Python >= 3.6.8: https://www.python.org/downloads/ +.. _MIT: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt +.. _Microsoft Open Source Code of Conduct: https://opensource.microsoft.com/codeofconduct/ +.. _Code of Conduct FAQ: https://opensource.microsoft.com/codeofconduct/faq/ +.. _opencode@microsoft.com: mailto:opencode@microsoft.com +.. _secure@microsoft.com: mailto:secure@microsoft.com +.. _MSRC PGP: https://technet.microsoft.com/en-us/security/dn606155 +.. _Security TechCenter: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt + +.. `_ \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/__init__.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/__init__.py new file mode 100644 index 000000000..949c1aa4f --- /dev/null +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/__init__.py @@ -0,0 +1,10 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .application_insights_telemetry_client import ApplicationInsightsTelemetryClient + +__all__ = ["ApplicationInsightsTelemetryClient"] diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py new file mode 100644 index 000000000..207928c56 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/about.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = 'botbuilder-applicationinsights' +__version__ = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.0.0.a6" +__uri__ = 'https://www.github.com/Microsoft/botbuilder-python' +__author__ = 'Microsoft' +__description__ = 'Microsoft Bot Framework Bot Builder' +__summary__ = 'Microsoft Bot Framework Bot Builder SDK for Python.' +__license__ = 'MIT' diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py new file mode 100644 index 000000000..3e63e0085 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import sys +import traceback +from applicationinsights import TelemetryClient +from .bot_telemetry_client import BotTelemetryClient, TelemetryDataPointType +from typing import Dict + +class ApplicationInsightsTelemetryClient(BotTelemetryClient): + + def __init__(self, instrumentation_key:str): + self._instrumentation_key = instrumentation_key + self._client = TelemetryClient(self._instrumentation_key) + + + def track_pageview(self, name: str, url:str, duration: int = 0, properties : Dict[str, object]=None, + measurements: Dict[str, object]=None) -> None: + """ + Send information about the page viewed in the application (a web page for instance). + :param name: the name of the page that was viewed. + :param url: the URL of the page that was viewed. + :param duration: the duration of the page view in milliseconds. (defaults to: 0) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + self._client.track_pageview(name, url, duration, properties, measurements) + + def track_exception(self, type_exception: type = None, value : Exception =None, tb : traceback =None, + properties: Dict[str, object]=None, measurements: Dict[str, object]=None) -> None: + """ + Send information about a single exception that occurred in the application. + :param type_exception: the type of the exception that was thrown. + :param value: the exception that the client wants to send. + :param tb: the traceback information as returned by :func:`sys.exc_info`. + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + self._client.track_exception(type_exception, value, tb, properties, measurements) + + def track_event(self, name: str, properties: Dict[str, object] = None, + measurements: Dict[str, object] = None) -> None: + """ + Send information about a single event that has occurred in the context of the application. + :param name: the data to associate to this event. + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + self._client.track_event(name, properties, measurements) + + def track_metric(self, name: str, value: float, type: TelemetryDataPointType =None, + count: int =None, min: float=None, max: float=None, std_dev: float=None, + properties: Dict[str, object]=None) -> NotImplemented: + """ + Send information about a single metric data point that was captured for the application. + :param name: The name of the metric that was captured. + :param value: The value of the metric that was captured. + :param type: The type of the metric. (defaults to: TelemetryDataPointType.aggregation`) + :param count: the number of metrics that were aggregated into this data point. (defaults to: None) + :param min: the minimum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param max: the maximum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param std_dev: the standard deviation of all metrics collected that were aggregated into this data point. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + """ + self._client.track_metric(name, value, type, count, min, max, std_dev, properties) + + def track_trace(self, name: str, properties: Dict[str, object]=None, severity=None): + """ + Sends a single trace statement. + :param name: the trace statement.\n + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None)\n + :param severity: the severity level of this trace, one of DEBUG, INFO, WARNING, ERROR, CRITICAL + """ + self._client.track_trace(name, properties, severity) + + def track_request(self, name: str, url: str, success: bool, start_time: str=None, + duration: int=None, response_code: str =None, http_method: str=None, + properties: Dict[str, object]=None, measurements: Dict[str, object]=None, + request_id: str=None): + """ + Sends a single request that was captured for the application. + :param name: The name for this request. All requests with the same name will be grouped together. + :param url: The actual URL for this request (to show in individual request instances). + :param success: True if the request ended in success, False otherwise. + :param start_time: the start time of the request. The value should look the same as the one returned by :func:`datetime.isoformat()` (defaults to: None) + :param duration: the number of milliseconds that this request lasted. (defaults to: None) + :param response_code: the response code that this request returned. (defaults to: None) + :param http_method: the HTTP method that triggered this request. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param request_id: the id for this request. If None, a new uuid will be generated. (defaults to: None) + """ + self._client.track_request(name, url, success, start_time, duration, response_code, http_method, properties, + measurements, request_id) + + def track_dependency(self, name:str, data:str, type:str=None, target:str=None, duration:int=None, + success:bool=None, result_code:str=None, properties:Dict[str, object]=None, + measurements:Dict[str, object]=None, dependency_id:str=None): + """ + Sends a single dependency telemetry that was captured for the application. + :param name: the name of the command initiated with this dependency call. Low cardinality value. Examples are stored procedure name and URL path template. + :param data: the command initiated by this dependency call. Examples are SQL statement and HTTP URL with all query parameters. + :param type: the dependency type name. Low cardinality value for logical grouping of dependencies and interpretation of other fields like commandName and resultCode. Examples are SQL, Azure table, and HTTP. (default to: None) + :param target: the target site of a dependency call. Examples are server name, host address. (default to: None) + :param duration: the number of milliseconds that this dependency call lasted. (defaults to: None) + :param success: true if the dependency call ended in success, false otherwise. (defaults to: None) + :param result_code: the result code of a dependency call. Examples are SQL error code and HTTP status code. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param id: the id for this dependency call. If None, a new uuid will be generated. (defaults to: None) + """ + self._client.track_dependency(name, data, type, target, duration, success, result_code, properties, + measurements, dependency_id) + diff --git a/libraries/botbuilder-applicationinsights/requirements.txt b/libraries/botbuilder-applicationinsights/requirements.txt new file mode 100644 index 000000000..20e08fc54 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/requirements.txt @@ -0,0 +1,3 @@ +msrest>=0.6.6 +botbuilder-core>=4.0.0.a6 +aiounittest>=1.1.0 \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/setup.cfg b/libraries/botbuilder-applicationinsights/setup.cfg new file mode 100644 index 000000000..68c61a226 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=0 \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py new file mode 100644 index 000000000..4c6d52a97 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + 'aiounittest>=1.1.0', + 'botbuilder-schema>=4.0.0.a6', + 'botframework-connector>=4.0.0.a6', + 'botbuilder-core>=4.0.0.a6'] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, 'botbuilder', 'applicationinsights', 'about.py')) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +setup( + name=package_info['__title__'], + version=package_info['__version__'], + url=package_info['__uri__'], + author=package_info['__author__'], + description=package_info['__description__'], + keywords=['BotBuilderApplicationInsights', 'bots', 'ai', 'botframework', 'botbuilder'], + long_description=package_info['__summary__'], + license=package_info['__license__'], + packages=['botbuilder.applicationinsights'], + install_requires=REQUIRES, + include_package_data=True, + classifiers=[ + 'Programming Language :: Python :: 3.6', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Development Status :: 3 - Alpha', + 'Topic :: Scientific/Engineering :: Artificial Intelligence', + ] +) diff --git a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py new file mode 100644 index 000000000..73e44dc40 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +import aiounittest +from botbuilder.core.adapters import ( + TestAdapter, + TestFlow + ) +from botbuilder.schema import ( + Activity + ) +from botbuilder.core import ( + ConversationState, + MemoryStorage, + TurnContext, + NullTelemetryClient + ) +from botbuilder.dialogs import ( + Dialog, + DialogSet, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, + DialogContext, + DialogTurnStatus + ) + + +begin_message = Activity() +begin_message.text = 'begin' +begin_message.type = 'message' + +class TelemetryWaterfallTests(aiounittest.AsyncTestCase): + def test_none_telemetry_client(self): + dialog = WaterfallDialog("myId") + dialog.telemetry_client = None + self.assertEqual(type(dialog.telemetry_client), NullTelemetryClient) + + async def no_test_execute_sequence_waterfall_steps(self): + # Create new ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the WaterfallDialog. + dialog_state = convo_state.create_property('dialogState') + dialogs = DialogSet(dialog_state) + async def step1(step) -> DialogTurnResult: + print('IN STEP 1') + await step.context.send_activity('bot responding.') + return Dialog.end_of_turn + + async def step2(step) -> DialogTurnResult: + print('IN STEP 2') + return await step.end_dialog('ending WaterfallDialog.') + + mydialog = WaterfallDialog('test', [ step1, step2 ]) + await dialogs.add(mydialog) + + # Initialize TestAdapter + async def exec_test(turn_context: TurnContext) -> None: + + dc = await dialogs.create_context(turn_context) + results = await dc.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dc.begin_dialog('test') + else: + if results.status == DialogTurnStatus.Complete: + await turn_context.send_activity(results.result) + await convo_state.save_changes(turn_context) + + adapt = TestAdapter(exec_test) + + tf = TestFlow(None, adapt) + tf2 = await tf.send(begin_message) + tf3 = await tf2.assert_reply('bot responding.') + tf4 = await tf3.send('continue') + tf5 = await tf4.assert_reply('ending WaterfallDialog.') diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index 3eea39743..99bc69f68 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -11,11 +11,13 @@ from .bot_adapter import BotAdapter from .bot_framework_adapter import BotFrameworkAdapter, BotFrameworkAdapterSettings from .bot_state import BotState +from .bot_telemetry_client import BotTelemetryClient from .card_factory import CardFactory from .conversation_state import ConversationState from .memory_storage import MemoryStorage from .message_factory import MessageFactory from .middleware_set import AnonymousReceiveMiddleware, Middleware, MiddlewareSet +from .null_telemetry_client import NullTelemetryClient from .state_property_accessor import StatePropertyAccessor from .state_property_info import StatePropertyInfo from .storage import Storage, StoreItem, StorageKeyFactory, calculate_change_hash @@ -36,6 +38,7 @@ 'MessageFactory', 'Middleware', 'MiddlewareSet', + 'NullBotTelemetryClient', 'StatePropertyAccessor', 'StatePropertyInfo', 'Storage', diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py index fabe092b5..540ff61e7 100644 --- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py @@ -199,16 +199,10 @@ async def assert_reply(self, expected, description=None, timeout=None) -> 'TestF :param timeout: :return: """ - print('ASSERT REPLY') def default_inspector(reply, description=None): - print('HELLOOOO FROM THE INSPECTOR...........') if isinstance(expected, Activity): validate_activity(reply, expected) else: - print('EXPECTED') - print(expected) - #print('REPLYTEXT') - #print(reply.text) assert reply.type == 'message', description + f" type == {reply.type}" assert reply.text == expected, description + f" text == {reply.text}" @@ -242,9 +236,7 @@ async def wait_for_activity(): else: await asyncio.sleep(0.05) await wait_for_activity() - print('IN WAIT FOR PREVIOUS BEFORE WAIT FOR ACTIVITY') await wait_for_activity() - print('IN ASSERTREPLY BEFORE invoking new TESTFLOW') return TestFlow(await test_flow_previous(), self.adapter) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 8f4677e3a..94bfbdd08 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -12,6 +12,8 @@ from typing import Callable, Dict + + class CachedBotState: """ Internal cached bot state. @@ -33,7 +35,7 @@ def hash(self) -> str: @hash.setter def hash(self, hash: str): - self._hash = hash; + self._hash = hash @property def is_changed(self) -> bool: @@ -70,8 +72,10 @@ async def load(self, turn_context: TurnContext, force: bool = False) -> None: """ if turn_context == None: raise TypeError('BotState.load(): turn_context cannot be None.') + cached_state = turn_context.turn_state.get(self._context_service_key) storage_key = self.get_storage_key(turn_context) + if (force or not cached_state or not cached_state.state) : items = await self._storage.read([storage_key]) val = items.get(storage_key) @@ -140,6 +144,8 @@ async def get_property_value(self, turn_context: TurnContext, property_name: str # This allows this to work with value types return cached_state.state[property_name] + + async def delete_property_value(self, turn_context: TurnContext, property_name: str) -> None: """ Deletes a property from the state cache in the turn context. @@ -185,10 +191,11 @@ async def delete(self, turn_context: TurnContext) -> None: await self._bot_state.load(turn_context, False) await self._bot_state.delete_property_value(turn_context, self._name) - async def get(self, turn_context: TurnContext, default_value_factory) -> object: + async def get(self, turn_context: TurnContext, default_value_factory : Callable = None) -> object: await self._bot_state.load(turn_context, False) try: - result = await _bot_state.get_property_value(turn_context, name) + + result = await self._bot_state.get_property_value(turn_context, self._name) return result except: # ask for default value from factory @@ -201,4 +208,4 @@ async def get(self, turn_context: TurnContext, default_value_factory) -> object: async def set(self, turn_context: TurnContext, value: object) -> None: await self._bot_state.load(turn_context, False) - await self._bot_state.set_property_value(turn_context, self.name, value) + await self._bot_state.set_property_value(turn_context, self._name, value) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py index 1808bc571..d1d7d4da8 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py @@ -2,11 +2,12 @@ # Licensed under the MIT License. from abc import ABC, abstractmethod -from botbuilder.core.turn_context import TurnContext +from botbuilder.core import (TurnContext, NullTelemetryClient, BotTelemetryClient) from .dialog_reason import DialogReason from .dialog_turn_status import DialogTurnStatus from .dialog_turn_result import DialogTurnResult + class Dialog(ABC): end_of_turn = DialogTurnResult(DialogTurnStatus.Waiting) @@ -14,13 +15,30 @@ def __init__(self, dialog_id: str): if dialog_id == None or not dialog_id.strip(): raise TypeError('Dialog(): dialogId cannot be None.') - self.telemetry_client = None; # TODO: Make this NullBotTelemetryClient() + self._telemetry_client = NullTelemetryClient() self._id = dialog_id @property def id(self) -> str: return self._id + @property + def telemetry_client(self) -> BotTelemetryClient: + """ + Gets the telemetry client for logging events. + """ + return self._telemetry_client + + @telemetry_client.setter + def telemetry_client(self, value: BotTelemetryClient) -> None: + """ + Sets the telemetry client for logging events. + """ + if value is None: + self._telemetry_client = NullTelemetryClient() + else: + self._telemetry_client = value + @abstractmethod async def begin_dialog(self, dc, options: object = None): """ @@ -40,7 +58,7 @@ async def continue_dialog(self, dc): :return: """ # By default just end the current dialog. - return await dc.EndDialog(None) + return await dc.end_dialog(None) async def resume_dialog(self, dc, reason: DialogReason, result: object): """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index 767e899d1..ae31fcd2e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -126,7 +126,7 @@ async def prompt(self, dialog_id: str, options) -> DialogTurnResult: return await self.begin_dialog(dialog_id, options) - async def continue_dialog(self, dc, reason: DialogReason, result: object): + async def continue_dialog(self): """ Continues execution of the active dialog, if there is one, by passing the context object to its `Dialog.continue_dialog()` method. You can check `turn_context.responded` after the call completes @@ -161,7 +161,7 @@ async def end_dialog(self, result: object = None): await self.end_active_dialog(DialogReason.EndCalled) # Resume previous dialog - if not self.active_dialog: + if self.active_dialog != None: # Look up dialog dialog = await self.find_dialog(self.active_dialog.id) if not dialog: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index 459338687..128837d54 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -39,7 +39,6 @@ def state(self) -> Dict[str, object]: :param: :return Dict[str, object]: """ - return self._state @state.setter @@ -50,4 +49,12 @@ def state(self, value: Dict[str, object]) -> None: :param value: The instance's persisted state. :return: """ + self._state = value + + def __str__(self): + result = "\ndialog_instance_id: %s\n" % self.id + if not self._state is None: + for key, value in self._state.items(): + result += " {} ({})\n".format(key, str(value)) + return result \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index 858f52b4b..58b44265f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -33,21 +33,18 @@ def __init__(self, dialog_id: str, steps: [Coroutine] = None): raise TypeError('WaterfallDialog(): steps must be list of steps') self._steps = steps - # TODO: Add WaterfallStep class def add_step(self, step): - """Adds a new step to the waterfall. - Parameters - ---------- - step - Step to add + """ + Adds a new step to the waterfall. + :param step: Step to add - Returns - ------- - WaterfallDialog - Waterfall dialog for fluent calls to `add_step()`. + :return: Waterfall dialog for fluent calls to `add_step()`. """ if not step: raise TypeError('WaterfallDialog.add_step(): step cannot be None.') + + self._steps.append(step) + return self async def begin_dialog(self, dc: DialogContext, options: object = None) -> DialogTurnResult: @@ -65,11 +62,12 @@ async def begin_dialog(self, dc: DialogContext, options: object = None) -> Dialo properties = {} properties['dialog_id'] = id properties['instance_id'] = instance_id - + self.telemetry_client.track_event("WaterfallStart", properties) + # Run first stepkinds return await self.run_step(dc, 0, DialogReason.BeginCalled, None) - async def continue_dialog(self, dc: DialogContext, reason: DialogReason, result: object) -> DialogTurnResult: + async def continue_dialog_ext(self, dc: DialogContext, reason: DialogReason, result: object) -> DialogTurnResult: if not dc: raise TypeError('WaterfallDialog.continue_dialog(): dc cannot be None.') @@ -91,11 +89,35 @@ async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: o return self.run_step(dc, state[self.StepIndex] + 1, reason, result) async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, reason: DialogReason): - # TODO: Add telemetry logging + if reason is DialogReason.CancelCalled: + index = instance.state[self.StepIndex] + step_name = self.get_step_name(index) + instance_id = str(instance.state[self.PersistedInstanceId]) + properties = { + "DialogId": self.id, + "StepName" : step_name, + "InstanceId" : instance_id + } + self.telemetry_client.track_event("WaterfallCancel", properties) + else: + if reason is DialogReason.EndCalled: + instance_id = str(instance.state[self.PersistedInstanceId]) + properties = { + "DialogId", self.id, + "InstanceId", instance_id + } + self.telemetry_client.track_event("WaterfallCancel", properties) return async def on_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: - # TODO: Add telemetry logging + step_name = self.get_step_name(step_context.index) + instance_id = str(step_context.active_dialog.state[self.PersistedInstanceId]) + properties = { + "DialogId", self.id, + "StepName", step_name, + "InstanceId", instance_id + } + self.telemetry_client.track_event("WaterfallStep", properties) return await self._steps[step_context.index](step_context) async def run_step(self, dc: DialogContext, index: int, reason: DialogReason, result: object) -> DialogTurnResult: @@ -114,3 +136,14 @@ async def run_step(self, dc: DialogContext, index: int, reason: DialogReason, re else: # End of waterfall so just return any result to parent return dc.end_dialog(result) + + def get_step_name(self, index: int) -> str: + """ + Give the waterfall step a unique name + """ + step_name = self._steps[index].__qualname__ + + if not step_name: + step_name = f"Step{index + 1}of{len(self._steps)}" + + return step_name \ No newline at end of file diff --git a/libraries/botbuilder-dialogs/tests/test_waterfall.py b/libraries/botbuilder-dialogs/tests/test_waterfall.py index bdcdb3806..c5a912498 100644 --- a/libraries/botbuilder-dialogs/tests/test_waterfall.py +++ b/libraries/botbuilder-dialogs/tests/test_waterfall.py @@ -3,7 +3,10 @@ import aiounittest -from botbuilder.core.test_adapter import TestAdapter, TestFlow +from botbuilder.core.adapters import ( + TestAdapter, + TestFlow + ) from botbuilder.schema import ( Activity ) @@ -24,7 +27,7 @@ class MyWaterfallDialog(WaterfallDialog): def __init__(self, id: str): - super(WaterfallDialog, self).__init__(id) + super(MyWaterfallDialog, self).__init__(id) async def Waterfall2_Step1(step_context: WaterfallStepContext) -> DialogTurnResult: await step_context.context.send_activity("step1") return Dialog.end_of_turn @@ -58,7 +61,7 @@ async def test_waterfall_with_set_instead_of_array(self): # TODO:WORK IN PROGRESS - async def no_test_execute_sequence_waterfall_steps(self): + async def test_execute_sequence_waterfall_steps(self): # Create new ConversationState with MemoryStorage and register the state as middleware. convo_state = ConversationState(MemoryStorage()) @@ -66,12 +69,12 @@ async def no_test_execute_sequence_waterfall_steps(self): dialog_state = convo_state.create_property('dialogState') dialogs = DialogSet(dialog_state) async def step1(step) -> DialogTurnResult: - assert(step, 'hey!') + print('IN STEP 1') await step.context.send_activity('bot responding.') return Dialog.end_of_turn async def step2(step) -> DialogTurnResult: - assert(step) + print('IN STEP 2') return await step.end_dialog('ending WaterfallDialog.') mydialog = WaterfallDialog('test', [ step1, step2 ]) @@ -87,6 +90,7 @@ async def exec_test(turn_context: TurnContext) -> None: else: if results.status == DialogTurnStatus.Complete: await turn_context.send_activity(results.result) + print('SAVING CONVERSATION') await convo_state.save_changes(turn_context) adapt = TestAdapter(exec_test) From 0d0d14b80560585d61d7a75a1ee4353a4b54d45a Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Tue, 23 Apr 2019 11:18:56 -0700 Subject: [PATCH 29/73] Forgot a few files --- .../botbuilder/core/bot_telemetry_client.py | 117 ++++++++++++++++++ .../botbuilder/core/null_telemetry_client.py | 108 ++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py create mode 100644 libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py b/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py new file mode 100644 index 000000000..1e4143f3c --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/bot_telemetry_client.py @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import traceback +import sys +from abc import ABC, abstractmethod +from typing import Dict +from enum import Enum + +class TelemetryDataPointType(Enum): + measurement = 0 + aggregation = 1 + +class BotTelemetryClient(ABC): + @abstractmethod + def track_pageview(self, name: str, url, duration: int = 0, properties : Dict[str, object]=None, + measurements: Dict[str, object]=None) -> None: + """ + Send information about the page viewed in the application (a web page for instance). + :param name: the name of the page that was viewed. + :param url: the URL of the page that was viewed. + :param duration: the duration of the page view in milliseconds. (defaults to: 0) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + raise NotImplementedError('BotTelemetryClient.track_request(): is not implemented.') + + @abstractmethod + def track_exception(self, type: type = None, value : Exception =None, tb : traceback =None, + properties: Dict[str, object]=None, measurements: Dict[str, object]=None) -> None: + """ + Send information about a single exception that occurred in the application. + :param type: the type of the exception that was thrown. + :param value: the exception that the client wants to send. + :param tb: the traceback information as returned by :func:`sys.exc_info`. + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + raise NotImplementedError('BotTelemetryClient.track_request(): is not implemented.') + + @abstractmethod + def track_event(self, name: str, properties: Dict[str, object] = None, + measurements: Dict[str, object] = None) -> None: + """ + Send information about a single event that has occurred in the context of the application. + :param name: the data to associate to this event. + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + raise NotImplementedError('BotTelemetryClient.track_event(): is not implemented.') + + @abstractmethod + def track_metric(self, name: str, value: float, type: TelemetryDataPointType =None, + count: int =None, min: float=None, max: float=None, std_dev: float=None, + properties: Dict[str, object]=None) -> NotImplemented: + """ + Send information about a single metric data point that was captured for the application. + :param name: The name of the metric that was captured. + :param value: The value of the metric that was captured. + :param type: The type of the metric. (defaults to: TelemetryDataPointType.aggregation`) + :param count: the number of metrics that were aggregated into this data point. (defaults to: None) + :param min: the minimum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param max: the maximum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param std_dev: the standard deviation of all metrics collected that were aggregated into this data point. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + """ + raise NotImplementedError('BotTelemetryClient.track_metric(): is not implemented.') + + @abstractmethod + def track_trace(self, name, properties=None, severity=None): + """ + Sends a single trace statement. + :param name: the trace statement.\n + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None)\n + :param severity: the severity level of this trace, one of DEBUG, INFO, WARNING, ERROR, CRITICAL + """ + raise NotImplementedError('BotTelemetryClient.track_trace(): is not implemented.') + + @abstractmethod + def track_request(self, name: str, url: str, success: bool, start_time: str=None, + duration: int=None, response_code: str =None, http_method: str=None, + properties: Dict[str, object]=None, measurements: Dict[str, object]=None, + request_id: str=None): + """ + Sends a single request that was captured for the application. + :param name: The name for this request. All requests with the same name will be grouped together. + :param url: The actual URL for this request (to show in individual request instances). + :param success: True if the request ended in success, False otherwise. + :param start_time: the start time of the request. The value should look the same as the one returned by :func:`datetime.isoformat()` (defaults to: None) + :param duration: the number of milliseconds that this request lasted. (defaults to: None) + :param response_code: the response code that this request returned. (defaults to: None) + :param http_method: the HTTP method that triggered this request. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param request_id: the id for this request. If None, a new uuid will be generated. (defaults to: None) + """ + raise NotImplementedError('BotTelemetryClient.track_request(): is not implemented.') + + @abstractmethod + def track_dependency(self, name:str, data:str, type:str=None, target:str=None, duration:int=None, + success:bool=None, result_code:str=None, properties:Dict[str, object]=None, + measurements:Dict[str, object]=None, dependency_id:str=None): + """ + Sends a single dependency telemetry that was captured for the application. + :param name: the name of the command initiated with this dependency call. Low cardinality value. Examples are stored procedure name and URL path template. + :param data: the command initiated by this dependency call. Examples are SQL statement and HTTP URL with all query parameters. + :param type: the dependency type name. Low cardinality value for logical grouping of dependencies and interpretation of other fields like commandName and resultCode. Examples are SQL, Azure table, and HTTP. (default to: None) + :param target: the target site of a dependency call. Examples are server name, host address. (default to: None) + :param duration: the number of milliseconds that this dependency call lasted. (defaults to: None) + :param success: true if the dependency call ended in success, false otherwise. (defaults to: None) + :param result_code: the result code of a dependency call. Examples are SQL error code and HTTP status code. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param id: the id for this dependency call. If None, a new uuid will be generated. (defaults to: None) + """ + raise NotImplementedError('BotTelemetryClient.track_dependency(): is not implemented.') + diff --git a/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py b/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py new file mode 100644 index 000000000..9ba50c666 --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/null_telemetry_client.py @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import sys +import traceback +from .bot_telemetry_client import BotTelemetryClient, TelemetryDataPointType +from typing import Dict + +class NullTelemetryClient(BotTelemetryClient): + + def __init__(self): + pass + + def track_pageview(self, name: str, url, duration: int = 0, properties : Dict[str, object]=None, + measurements: Dict[str, object]=None) -> None: + """ + Send information about the page viewed in the application (a web page for instance). + :param name: the name of the page that was viewed. + :param url: the URL of the page that was viewed. + :param duration: the duration of the page view in milliseconds. (defaults to: 0) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + pass + + def track_exception(self, type: type = None, value : Exception =None, tb : traceback =None, + properties: Dict[str, object]=None, measurements: Dict[str, object]=None) -> None: + """ + Send information about a single exception that occurred in the application. + :param type: the type of the exception that was thrown. + :param value: the exception that the client wants to send. + :param tb: the traceback information as returned by :func:`sys.exc_info`. + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + pass + + def track_event(self, name: str, properties: Dict[str, object] = None, + measurements: Dict[str, object] = None) -> None: + """ + Send information about a single event that has occurred in the context of the application. + :param name: the data to associate to this event. + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + pass + + def track_metric(self, name: str, value: float, type: TelemetryDataPointType =None, + count: int =None, min: float=None, max: float=None, std_dev: float=None, + properties: Dict[str, object]=None) -> NotImplemented: + """ + Send information about a single metric data point that was captured for the application. + :param name: The name of the metric that was captured. + :param value: The value of the metric that was captured. + :param type: The type of the metric. (defaults to: TelemetryDataPointType.aggregation`) + :param count: the number of metrics that were aggregated into this data point. (defaults to: None) + :param min: the minimum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param max: the maximum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param std_dev: the standard deviation of all metrics collected that were aggregated into this data point. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + """ + pass + + def track_trace(self, name, properties=None, severity=None): + """ + Sends a single trace statement. + :param name: the trace statement.\n + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None)\n + :param severity: the severity level of this trace, one of DEBUG, INFO, WARNING, ERROR, CRITICAL + """ + pass + + def track_request(self, name: str, url: str, success: bool, start_time: str=None, + duration: int=None, response_code: str =None, http_method: str=None, + properties: Dict[str, object]=None, measurements: Dict[str, object]=None, + request_id: str=None): + """ + Sends a single request that was captured for the application. + :param name: The name for this request. All requests with the same name will be grouped together. + :param url: The actual URL for this request (to show in individual request instances). + :param success: True if the request ended in success, False otherwise. + :param start_time: the start time of the request. The value should look the same as the one returned by :func:`datetime.isoformat()` (defaults to: None) + :param duration: the number of milliseconds that this request lasted. (defaults to: None) + :param response_code: the response code that this request returned. (defaults to: None) + :param http_method: the HTTP method that triggered this request. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param request_id: the id for this request. If None, a new uuid will be generated. (defaults to: None) + """ + pass + + def track_dependency(self, name:str, data:str, type:str=None, target:str=None, duration:int=None, + success:bool=None, result_code:str=None, properties:Dict[str, object]=None, + measurements:Dict[str, object]=None, dependency_id:str=None): + """ + Sends a single dependency telemetry that was captured for the application. + :param name: the name of the command initiated with this dependency call. Low cardinality value. Examples are stored procedure name and URL path template. + :param data: the command initiated by this dependency call. Examples are SQL statement and HTTP URL with all query parameters. + :param type: the dependency type name. Low cardinality value for logical grouping of dependencies and interpretation of other fields like commandName and resultCode. Examples are SQL, Azure table, and HTTP. (default to: None) + :param target: the target site of a dependency call. Examples are server name, host address. (default to: None) + :param duration: the number of milliseconds that this dependency call lasted. (defaults to: None) + :param success: true if the dependency call ended in success, false otherwise. (defaults to: None) + :param result_code: the result code of a dependency call. Examples are SQL error code and HTTP status code. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param id: the id for this dependency call. If None, a new uuid will be generated. (defaults to: None) + """ + pass + From 200b1555b46c1aac72f70a739c45a652ceed1973 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 23 Apr 2019 11:18:58 -0700 Subject: [PATCH 30/73] fixes in bot sate, several tests still pending --- .../botbuilder-core/botbuilder/core/bot_state.py | 8 ++++---- libraries/botbuilder-core/tests/test_bot_state.py | 11 +++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 47d73b3e3..9c7f7033d 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -33,7 +33,7 @@ def hash(self) -> str: @hash.setter def hash(self, hash: str): - self._hash = hash; + self._hash = hash @property def is_changed(self) -> bool: @@ -186,10 +186,10 @@ async def delete(self, turn_context: TurnContext) -> None: await self._bot_state.load(turn_context, False) await self._bot_state.delete_property_value(turn_context, self._name) - async def get(self, turn_context: TurnContext, default_value_factory) -> object: + async def get(self, turn_context: TurnContext, default_value_factory = None) -> object: await self._bot_state.load(turn_context, False) try: - result = await _bot_state.get_property_value(turn_context, name) + result = await self._bot_state.get_property_value(turn_context, self._name) return result except: # ask for default value from factory @@ -202,4 +202,4 @@ async def get(self, turn_context: TurnContext, default_value_factory) -> object: async def set(self, turn_context: TurnContext, value: object) -> None: await self._bot_state.load(turn_context, False) - await self._bot_state.set_property_value(turn_context, self.name, value) + await self._bot_state.set_property_value(turn_context, self._name, value) diff --git a/libraries/botbuilder-core/tests/test_bot_state.py b/libraries/botbuilder-core/tests/test_bot_state.py index 07bcdf0c5..6b716b33d 100644 --- a/libraries/botbuilder-core/tests/test_bot_state.py +++ b/libraries/botbuilder-core/tests/test_bot_state.py @@ -53,9 +53,15 @@ async def test_storage_not_called_no_changes(self): """Verify storage not called when no changes are made""" # Mock a storage provider, which counts read/writes dictionary = {} + + async def mock_write_result(self): + return + async def mock_read_result(self): + return {} + mock_storage = MemoryStorage(dictionary) - mock_storage.write = MagicMock(return_value= 1) - mock_storage.read = MagicMock(return_value= 1) + mock_storage.write = MagicMock(side_effect= mock_write_result) + mock_storage.read = MagicMock(side_effect= mock_read_result) # Arrange user_state = UserState(mock_storage) @@ -72,6 +78,7 @@ async def test_storage_not_called_no_changes(self): self.assertEqual(mock_storage.write.call_count, 0) # Set on property should not bump await user_state.save_changes(context) self.assertEqual(mock_storage.write.call_count, 1) # Explicit save should bump + #import pdb; pdb.set_trace() valueA = await propertyA.get(context) self.assertEqual("there", valueA) self.assertEqual(mock_storage.write.call_count, 1) # Gets should not bump From 6d5ca5c50ca72ae37eb87e5645c793932363fa48 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Tue, 23 Apr 2019 11:21:15 -0700 Subject: [PATCH 31/73] disable failing test --- libraries/botbuilder-dialogs/tests/test_waterfall.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-dialogs/tests/test_waterfall.py b/libraries/botbuilder-dialogs/tests/test_waterfall.py index c5a912498..96ee26367 100644 --- a/libraries/botbuilder-dialogs/tests/test_waterfall.py +++ b/libraries/botbuilder-dialogs/tests/test_waterfall.py @@ -61,7 +61,7 @@ async def test_waterfall_with_set_instead_of_array(self): # TODO:WORK IN PROGRESS - async def test_execute_sequence_waterfall_steps(self): + async def no_test_execute_sequence_waterfall_steps(self): # Create new ConversationState with MemoryStorage and register the state as middleware. convo_state = ConversationState(MemoryStorage()) From 2abe98beba99f418c07b7a473ddb1f8070f76723 Mon Sep 17 00:00:00 2001 From: congysu Date: Fri, 19 Apr 2019 15:55:56 -0700 Subject: [PATCH 32/73] subtest for channel * data driven unittest with subtest --- .../tests/choices/test_channel.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/libraries/botbuilder-dialogs/tests/choices/test_channel.py b/libraries/botbuilder-dialogs/tests/choices/test_channel.py index f42c4f179..2285e1fe0 100644 --- a/libraries/botbuilder-dialogs/tests/choices/test_channel.py +++ b/libraries/botbuilder-dialogs/tests/choices/test_channel.py @@ -2,8 +2,11 @@ # Licensed under the MIT License. import unittest +from typing import List, Tuple +from botbuilder.core import BotFrameworkAdapter, TurnContext from botbuilder.dialogs.choices import Channel +from botbuilder.schema import Activity from botframework.connector import Channels @@ -11,3 +14,55 @@ class ChannelTest(unittest.TestCase): def test_supports_suggested_actions(self): actual = Channel.supports_suggested_actions(Channels.facebook, 5) self.assertTrue(actual) + + def test_supports_suggested_actions_many(self): + supports_suggested_actions_data: List[Tuple[Channels, int, bool]] = [ + (Channels.line, 13, True), + (Channels.line, 14, False), + (Channels.skype, 10, True), + (Channels.skype, 11, False), + (Channels.kik, 20, True), + (Channels.kik, 21, False), + (Channels.emulator, 100, True), + (Channels.emulator, 101, False), + ] + + for channel, button_cnt, expected in supports_suggested_actions_data: + with self.subTest( + channel=channel, button_cnt=button_cnt, expected=expected + ): + actual = Channel.supports_suggested_actions(channel, button_cnt) + self.assertEqual(expected, actual) + + def test_supports_card_actions_many(self): + supports_card_action_data: List[Tuple[Channels, int, bool]] = [ + (Channels.line, 99, True), + (Channels.line, 100, False), + (Channels.cortana, 100, True), + (Channels.slack, 100, True), + (Channels.skype, 3, True), + (Channels.skype, 5, False), + ] + + for channel, button_cnt, expected in supports_card_action_data: + with self.subTest( + channel=channel, button_cnt=button_cnt, expected=expected + ): + actual = Channel.supports_card_actions(channel, button_cnt) + self.assertEqual(expected, actual) + + def test_should_return_false_for_has_message_feed_with_cortana(self): + supports = Channel.has_message_feed(Channels.cortana) + self.assertFalse(supports) + + def test_should_return_channel_id_from_context_activity(self): + test_activity = Activity(channel_id=Channels.facebook) + test_context = TurnContext(BotFrameworkAdapter(settings=None), test_activity) + channel_id = Channel.get_channel_id(test_context) + self.assertEqual(Channels.facebook, channel_id) + + def test_should_return_empty_from_context_activity_missing_channel(self): + test_activity = Activity(channel_id=None) + test_context = TurnContext(BotFrameworkAdapter(settings=None), test_activity) + channel_id = Channel.get_channel_id(test_context) + self.assertEqual("", channel_id) From 9f06ceaf50fdfe548fac676f25e89531b71317f7 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Tue, 23 Apr 2019 12:12:28 -0700 Subject: [PATCH 33/73] Initial Testflow test --- .../tests/test_telemetry_waterfall.py | 4 +--- libraries/botbuilder-core/botbuilder/core/turn_context.py | 1 + .../botbuilder/dialogs/waterfall_dialog.py | 4 ++-- libraries/botbuilder-dialogs/tests/test_waterfall.py | 7 ++----- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py index 73e44dc40..cc4c0f315 100644 --- a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py +++ b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py @@ -37,7 +37,7 @@ def test_none_telemetry_client(self): dialog.telemetry_client = None self.assertEqual(type(dialog.telemetry_client), NullTelemetryClient) - async def no_test_execute_sequence_waterfall_steps(self): + async def test_execute_sequence_waterfall_steps(self): # Create new ConversationState with MemoryStorage and register the state as middleware. convo_state = ConversationState(MemoryStorage()) @@ -45,12 +45,10 @@ async def no_test_execute_sequence_waterfall_steps(self): dialog_state = convo_state.create_property('dialogState') dialogs = DialogSet(dialog_state) async def step1(step) -> DialogTurnResult: - print('IN STEP 1') await step.context.send_activity('bot responding.') return Dialog.end_of_turn async def step2(step) -> DialogTurnResult: - print('IN STEP 2') return await step.end_dialog('ending WaterfallDialog.') mydialog = WaterfallDialog('test', [ step1, step2 ]) diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 952d33f01..251aa4abe 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -136,6 +136,7 @@ async def send_activity(self, *activity_or_text: Union[Activity, str]) -> Resour :return: """ reference = TurnContext.get_conversation_reference(self.activity) + output = [TurnContext.apply_conversation_reference( Activity(text=a, type='message') if isinstance(a, str) else a, reference) for a in activity_or_text] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index 58b44265f..5fb731acc 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -67,7 +67,7 @@ async def begin_dialog(self, dc: DialogContext, options: object = None) -> Dialo # Run first stepkinds return await self.run_step(dc, 0, DialogReason.BeginCalled, None) - async def continue_dialog_ext(self, dc: DialogContext, reason: DialogReason, result: object) -> DialogTurnResult: + async def continue_dialog(self, dc: DialogContext = None, reason: DialogReason = None, result: object = NotImplementedError) -> DialogTurnResult: if not dc: raise TypeError('WaterfallDialog.continue_dialog(): dc cannot be None.') @@ -86,7 +86,7 @@ async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: o # Future Me: # If issues with CosmosDB, see https://github.com/Microsoft/botbuilder-dotnet/issues/871 # for hints. - return self.run_step(dc, state[self.StepIndex] + 1, reason, result) + return await self.run_step(dc, state[self.StepIndex] + 1, reason, result) async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, reason: DialogReason): if reason is DialogReason.CancelCalled: diff --git a/libraries/botbuilder-dialogs/tests/test_waterfall.py b/libraries/botbuilder-dialogs/tests/test_waterfall.py index 96ee26367..e4166b2a7 100644 --- a/libraries/botbuilder-dialogs/tests/test_waterfall.py +++ b/libraries/botbuilder-dialogs/tests/test_waterfall.py @@ -61,7 +61,7 @@ async def test_waterfall_with_set_instead_of_array(self): # TODO:WORK IN PROGRESS - async def no_test_execute_sequence_waterfall_steps(self): + async def test_execute_sequence_waterfall_steps(self): # Create new ConversationState with MemoryStorage and register the state as middleware. convo_state = ConversationState(MemoryStorage()) @@ -69,12 +69,10 @@ async def no_test_execute_sequence_waterfall_steps(self): dialog_state = convo_state.create_property('dialogState') dialogs = DialogSet(dialog_state) async def step1(step) -> DialogTurnResult: - print('IN STEP 1') await step.context.send_activity('bot responding.') return Dialog.end_of_turn async def step2(step) -> DialogTurnResult: - print('IN STEP 2') return await step.end_dialog('ending WaterfallDialog.') mydialog = WaterfallDialog('test', [ step1, step2 ]) @@ -84,13 +82,12 @@ async def step2(step) -> DialogTurnResult: async def exec_test(turn_context: TurnContext) -> None: dc = await dialogs.create_context(turn_context) - results = await dc.continue_dialog(dc, None, None) + results = await dc.continue_dialog() if results.status == DialogTurnStatus.Empty: await dc.begin_dialog('test') else: if results.status == DialogTurnStatus.Complete: await turn_context.send_activity(results.result) - print('SAVING CONVERSATION') await convo_state.save_changes(turn_context) adapt = TestAdapter(exec_test) From b7246cb5feb6da0340d4d20cd1b72ed142259047 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Tue, 23 Apr 2019 15:52:55 -0700 Subject: [PATCH 34/73] More tests for app insights --- .../application_insights_telemetry_client.py | 2 +- .../botbuilder-applicationinsights/setup.py | 1 + .../tests/test_telemetry_waterfall.py | 105 +++++++++++++++++- .../botbuilder/core/bot_state.py | 2 +- .../botbuilder/core/turn_context.py | 12 +- .../botbuilder/dialogs/dialog_context.py | 2 +- .../botbuilder/dialogs/waterfall_dialog.py | 25 +++-- 7 files changed, 125 insertions(+), 24 deletions(-) diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py index 3e63e0085..0fe48267b 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -3,7 +3,7 @@ import sys import traceback from applicationinsights import TelemetryClient -from .bot_telemetry_client import BotTelemetryClient, TelemetryDataPointType +from botbuilder.core.bot_telemetry_client import BotTelemetryClient, TelemetryDataPointType from typing import Dict class ApplicationInsightsTelemetryClient(BotTelemetryClient): diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py index 4c6d52a97..4349f16a9 100644 --- a/libraries/botbuilder-applicationinsights/setup.py +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -6,6 +6,7 @@ REQUIRES = [ 'aiounittest>=1.1.0', + 'applicationinsights >=0.11.8', 'botbuilder-schema>=4.0.0.a6', 'botframework-connector>=4.0.0.a6', 'botbuilder-core>=4.0.0.a6'] diff --git a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py index cc4c0f315..6d1dadb11 100644 --- a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py +++ b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py @@ -3,6 +3,10 @@ import aiounittest +from typing import Dict +from botbuilder.applicationinsights import ( + ApplicationInsightsTelemetryClient + ) from botbuilder.core.adapters import ( TestAdapter, TestFlow @@ -25,7 +29,7 @@ DialogContext, DialogTurnStatus ) - +from unittest.mock import patch, Mock begin_message = Activity() begin_message.text = 'begin' @@ -33,13 +37,21 @@ class TelemetryWaterfallTests(aiounittest.AsyncTestCase): def test_none_telemetry_client(self): + # arrange dialog = WaterfallDialog("myId") + # act dialog.telemetry_client = None + # assert self.assertEqual(type(dialog.telemetry_client), NullTelemetryClient) - async def test_execute_sequence_waterfall_steps(self): + @patch('test_telemetry_waterfall.ApplicationInsightsTelemetryClient') + async def test_execute_sequence_waterfall_steps(self, MockTelemetry): + # arrange + # Create new ConversationState with MemoryStorage and register the state as middleware. convo_state = ConversationState(MemoryStorage()) + telemetry = MockTelemetry() + # Create a DialogState property, DialogSet and register the WaterfallDialog. dialog_state = convo_state.create_property('dialogState') @@ -49,9 +61,13 @@ async def step1(step) -> DialogTurnResult: return Dialog.end_of_turn async def step2(step) -> DialogTurnResult: - return await step.end_dialog('ending WaterfallDialog.') + await step.context.send_activity('ending WaterfallDialog.') + return Dialog.end_of_turn + + # act mydialog = WaterfallDialog('test', [ step1, step2 ]) + mydialog.telemetry_client = telemetry await dialogs.add(mydialog) # Initialize TestAdapter @@ -73,3 +89,86 @@ async def exec_test(turn_context: TurnContext) -> None: tf3 = await tf2.assert_reply('bot responding.') tf4 = await tf3.send('continue') tf5 = await tf4.assert_reply('ending WaterfallDialog.') + + # assert + + telemetry_calls = [ ('WaterfallStart', {'DialogId':'test'}), + ('WaterfallStep', {'DialogId':'test', 'StepName':'Step1of2'}), + ('WaterfallStep', {'DialogId':'test', 'StepName':'Step2of2'}) + ] + self.assert_telemetry_calls(telemetry, telemetry_calls) + + @patch('test_telemetry_waterfall.ApplicationInsightsTelemetryClient') + async def test_ensure_end_dialog_called(self, MockTelemetry): + # arrange + + # Create new ConversationState with MemoryStorage and register the state as middleware. + convo_state = ConversationState(MemoryStorage()) + telemetry = MockTelemetry() + + + # Create a DialogState property, DialogSet and register the WaterfallDialog. + dialog_state = convo_state.create_property('dialogState') + dialogs = DialogSet(dialog_state) + async def step1(step) -> DialogTurnResult: + await step.context.send_activity('step1 response') + return Dialog.end_of_turn + + async def step2(step) -> DialogTurnResult: + await step.context.send_activity('step2 response') + return Dialog.end_of_turn + + # act + + mydialog = WaterfallDialog('test', [ step1, step2 ]) + mydialog.telemetry_client = telemetry + await dialogs.add(mydialog) + + # Initialize TestAdapter + async def exec_test(turn_context: TurnContext) -> None: + + dc = await dialogs.create_context(turn_context) + results = await dc.continue_dialog() + if turn_context.responded == False: + await dc.begin_dialog("test", None) + await convo_state.save_changes(turn_context) + + adapt = TestAdapter(exec_test) + + tf = TestFlow(None, adapt) + tf2 = await tf.send(begin_message) + tf3 = await tf2.assert_reply('step1 response') + tf4 = await tf3.send('continue') + tf5 = await tf4.assert_reply('step2 response') + await tf5.send('Should hit end of steps - this will restart the dialog and trigger COMPLETE event') + # assert + telemetry_calls = [ ('WaterfallStart', {'DialogId':'test'}), + ('WaterfallStep', {'DialogId':'test', 'StepName':'Step1of2'}), + ('WaterfallStep', {'DialogId':'test', 'StepName':'Step2of2'}), + ('WaterfallComplete', {'DialogId':'test'}), + ('WaterfallStart', {'DialogId':'test'}), + ('WaterfallStep', {'DialogId':'test', 'StepName':'Step1of2'}), + ] + self.assert_telemetry_calls(telemetry, telemetry_calls) + + + def assert_telemetry_call(self, telemetry_mock, index:int, event_name:str, props: Dict[str, str]) -> None: + args, kwargs = telemetry_mock.track_event.call_args_list[index] + self.assertEqual(args[0], event_name) + + for key, val in props.items(): + self.assertTrue(key in args[1], msg=f"Could not find value {key} in {args[1]} for index {index}") + self.assertTrue(type(args[1]) == dict) + self.assertTrue(val == args[1][key]) + + def assert_telemetry_calls(self, telemetry_mock, calls) -> None: + index = 0 + for event_name, props in calls: + self.assert_telemetry_call(telemetry_mock, index, event_name, props) + index += 1 + if index != len(telemetry_mock.track_event.call_args_list): + self.assertTrue(False, f"Found {len(telemetry_mock.track_event.call_args_list)} calls, testing for {index + 1}") + + + + diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 94bfbdd08..229de2b6a 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -39,7 +39,7 @@ def hash(self, hash: str): @property def is_changed(self) -> bool: - return hash != self.compute_hash(self._state) + return self.hash != self.compute_hash(self._state) def compute_hash(self, obj: object) -> str: # TODO: Should this be compatible with C# JsonConvert.SerializeObject ? diff --git a/libraries/botbuilder-core/botbuilder/core/turn_context.py b/libraries/botbuilder-core/botbuilder/core/turn_context.py index 251aa4abe..1e4326f4b 100644 --- a/libraries/botbuilder-core/botbuilder/core/turn_context.py +++ b/libraries/botbuilder-core/botbuilder/core/turn_context.py @@ -30,7 +30,7 @@ def __init__(self, adapter_or_context, request: Activity=None): self._on_send_activities: Callable[[]] = [] self._on_update_activity: Callable[[]] = [] self._on_delete_activity: Callable[[]] = [] - self._responded = {'responded': False} + self._responded : bool = False if self.adapter is None: raise TypeError('TurnContext must be instantiated with an adapter.') @@ -77,19 +77,19 @@ def activity(self, value): self._activity = value @property - def responded(self): + def responded(self) -> bool: """ If `true` at least one response has been sent for the current turn of conversation. :return: """ - return self._responded['responded'] + return self._responded @responded.setter - def responded(self, value): - if not value: + def responded(self, value: bool): + if value == False: raise ValueError('TurnContext: cannot set TurnContext.responded to False.') else: - self._responded['responded'] = True + self._responded = True @property def services(self): diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index ae31fcd2e..5ed78f83d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -233,7 +233,7 @@ async def end_active_dialog(self, reason: DialogReason): if instance != None: # Look up dialog dialog = await self.find_dialog(instance.id) - if not dialog: + if dialog != None: # Notify dialog of end await dialog.end_dialog(self.context, instance, reason) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py index 5fb731acc..cd8335381 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/waterfall_dialog.py @@ -60,10 +60,9 @@ async def begin_dialog(self, dc: DialogContext, options: object = None) -> Dialo state[self.PersistedInstanceId] = instance_id properties = {} - properties['dialog_id'] = id - properties['instance_id'] = instance_id + properties['DialogId'] = self.id + properties['InstanceId'] = instance_id self.telemetry_client.track_event("WaterfallStart", properties) - # Run first stepkinds return await self.run_step(dc, 0, DialogReason.BeginCalled, None) @@ -88,7 +87,7 @@ async def resume_dialog(self, dc: DialogContext, reason: DialogReason, result: o # for hints. return await self.run_step(dc, state[self.StepIndex] + 1, reason, result) - async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, reason: DialogReason): + async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, reason: DialogReason) -> None: if reason is DialogReason.CancelCalled: index = instance.state[self.StepIndex] step_name = self.get_step_name(index) @@ -101,21 +100,23 @@ async def end_dialog(self, turn_context: TurnContext, instance: DialogInstance, self.telemetry_client.track_event("WaterfallCancel", properties) else: if reason is DialogReason.EndCalled: + instance_id = str(instance.state[self.PersistedInstanceId]) properties = { - "DialogId", self.id, - "InstanceId", instance_id + "DialogId": self.id, + "InstanceId": instance_id } - self.telemetry_client.track_event("WaterfallCancel", properties) + self.telemetry_client.track_event("WaterfallComplete", properties) + return async def on_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: step_name = self.get_step_name(step_context.index) instance_id = str(step_context.active_dialog.state[self.PersistedInstanceId]) properties = { - "DialogId", self.id, - "StepName", step_name, - "InstanceId", instance_id + "DialogId": self.id, + "StepName": step_name, + "InstanceId": instance_id } self.telemetry_client.track_event("WaterfallStep", properties) return await self._steps[step_context.index](step_context) @@ -135,7 +136,7 @@ async def run_step(self, dc: DialogContext, index: int, reason: DialogReason, re return await self.on_step(step_context) else: # End of waterfall so just return any result to parent - return dc.end_dialog(result) + return await dc.end_dialog(result) def get_step_name(self, index: int) -> str: """ @@ -143,7 +144,7 @@ def get_step_name(self, index: int) -> str: """ step_name = self._steps[index].__qualname__ - if not step_name: + if not step_name or ">" in step_name : step_name = f"Step{index + 1}of{len(self._steps)}" return step_name \ No newline at end of file From 24b9ceffde3807fae110ac351869db72f384448c Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 23 Apr 2019 16:25:31 -0700 Subject: [PATCH 35/73] Basic bot state tests completed, pending to review default int property. TestFlow related tests still pending --- .../botbuilder/core/bot_state.py | 19 +- .../botbuilder/core/memory_storage.py | 2 +- .../botbuilder-core/tests/test_bot_state.py | 257 +++++++++++++++++- 3 files changed, 254 insertions(+), 24 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 6f6daa2c9..c307435d6 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -35,15 +35,11 @@ def hash(self) -> str: @hash.setter def hash(self, hash: str): -<<<<<<< HEAD - self._hash = hash -======= self._hash = hash ->>>>>>> work-in-progress @property def is_changed(self) -> bool: - return hash != self.compute_hash(self._state) + return self.hash != self.compute_hash(self._state) def compute_hash(self, obj: object) -> str: # TODO: Should this be compatible with C# JsonConvert.SerializeObject ? @@ -148,8 +144,6 @@ async def get_property_value(self, turn_context: TurnContext, property_name: str # This allows this to work with value types return cached_state.state[property_name] - - async def delete_property_value(self, turn_context: TurnContext, property_name: str) -> None: """ Deletes a property from the state cache in the turn context. @@ -164,7 +158,7 @@ async def delete_property_value(self, turn_context: TurnContext, property_name: if not property_name: raise TypeError('BotState.delete_property(): property_name cannot be None.') cached_state = turn_context.turn_state.get(self._context_service_key) - cached_state.state.remove(property_name) + del cached_state.state[property_name] async def set_property_value(self, turn_context: TurnContext, property_name: str, value: object) -> None: """ @@ -196,16 +190,9 @@ async def delete(self, turn_context: TurnContext) -> None: await self._bot_state.load(turn_context, False) await self._bot_state.delete_property_value(turn_context, self._name) -<<<<<<< HEAD - async def get(self, turn_context: TurnContext, default_value_factory = None) -> object: - await self._bot_state.load(turn_context, False) - try: -======= async def get(self, turn_context: TurnContext, default_value_factory : Callable = None) -> object: await self._bot_state.load(turn_context, False) - try: - ->>>>>>> work-in-progress + try: result = await self._bot_state.get_property_value(turn_context, self._name) return result except: diff --git a/libraries/botbuilder-core/botbuilder/core/memory_storage.py b/libraries/botbuilder-core/botbuilder/core/memory_storage.py index caa17c552..d602a1949 100644 --- a/libraries/botbuilder-core/botbuilder/core/memory_storage.py +++ b/libraries/botbuilder-core/botbuilder/core/memory_storage.py @@ -9,7 +9,7 @@ class MemoryStorage(Storage): def __init__(self, dictionary=None): super(MemoryStorage, self).__init__() - self.memory = dictionary or {} + self.memory = dictionary if dictionary is not None else {} self._e_tag = 0 async def delete(self, keys: List[str]): diff --git a/libraries/botbuilder-core/tests/test_bot_state.py b/libraries/botbuilder-core/tests/test_bot_state.py index 6b716b33d..f7671db3f 100644 --- a/libraries/botbuilder-core/tests/test_bot_state.py +++ b/libraries/botbuilder-core/tests/test_bot_state.py @@ -68,23 +68,22 @@ async def mock_read_result(self): context = TestUtilities.create_empty_context() # Act - propertyA = user_state.create_property("propertyA") + property_a = user_state.create_property("property_a") self.assertEqual(mock_storage.write.call_count, 0) await user_state.save_changes(context) - await propertyA.set(context, "hello") + await property_a.set(context, "hello") self.assertEqual(mock_storage.read.call_count, 1) # Initial save bumps count self.assertEqual(mock_storage.write.call_count, 0) # Initial save bumps count - await propertyA.set(context, "there") + await property_a.set(context, "there") self.assertEqual(mock_storage.write.call_count, 0) # Set on property should not bump await user_state.save_changes(context) self.assertEqual(mock_storage.write.call_count, 1) # Explicit save should bump - #import pdb; pdb.set_trace() - valueA = await propertyA.get(context) - self.assertEqual("there", valueA) + value_a = await property_a.get(context) + self.assertEqual("there", value_a) self.assertEqual(mock_storage.write.call_count, 1) # Gets should not bump await user_state.save_changes(context) self.assertEqual(mock_storage.write.call_count, 1) - await propertyA.DeleteAsync(context) # Delete alone no bump + await property_a.delete(context) # Delete alone no bump self.assertEqual(mock_storage.write.call_count, 1) await user_state.save_changes(context) # Save when dirty should bump self.assertEqual(mock_storage.write.call_count, 2) @@ -92,3 +91,247 @@ async def mock_read_result(self): await user_state.save_changes(context) # Save not dirty should not bump self.assertEqual(mock_storage.write.call_count, 2) self.assertEqual(mock_storage.read.call_count, 1) + + async def test_state_set_no_load(self): + """Should be able to set a property with no Load""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property_a") + await property_a.set(context, "hello") + + + + async def test_state_multiple_loads(self): + """Should be able to load multiple times""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property_a") + await user_state.load(context) + await user_state.load(context) + + + async def test_State_GetNoLoadWithDefault(self): + """Should be able to get a property with no Load and default""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property_a") + value_a = await property_a.get(context, lambda : "Default!") + self.assertEqual("Default!", value_a) + + + + async def test_State_GetNoLoadNoDefault(self): + """Cannot get a string with no default set""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property_a") + value_a = await property_a.get(context) + + # Assert + self.assertIsNone(value_a) + + + async def test_State_POCO_NoDefault(self): + """Cannot get a POCO with no default set""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + test_property = user_state.create_property("test") + value = await test_property.get(context) + + # Assert + self.assertIsNone(value) + + + + async def test_State_bool_NoDefault(self): + """Cannot get a bool with no default set""" + # Arange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + test_property = user_state.create_property("test") + value = await test_property.get(context) + + # Assert + self.assertFalse(value) + + """ + TODO: Check if default int functionality is needed + async def test_State_int_NoDefault(self): + ""Cannot get a int with no default set"" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + test_property = user_state.create_property("test") + value = await test_property.get(context) + + # Assert + self.assertEqual(0, value) + """ + + + async def test_State_SetAfterSave(self): + """Verify setting property after save""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await user_state.save_changes(context) + + await property_a.set(context, "hello2") + + + async def test_State_MultipleSave(self): + """Verify multiple saves""" + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await user_state.save_changes(context) + + await property_a.set(context, "hello2") + await user_state.save_changes(context) + value_a = await property_a.get(context) + self.assertEqual("hello2", value_a) + + + async def test_LoadSetSave(self): + # Arrange + dictionary = {} + user_state = UserState(MemoryStorage(dictionary)) + context = TestUtilities.create_empty_context() + + # Act + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await user_state.save_changes(context) + + # Assert + obj = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello", obj["property-a"]) + self.assertEqual("world", obj["property-b"]) + + + async def test_LoadSetSaveTwice(self): + # Arrange + dictionary = {} + context = TestUtilities.create_empty_context() + + # Act + user_state = UserState(MemoryStorage(dictionary)) + + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + propertyC = user_state.create_property("property-c") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await propertyC.set(context, "test") + await user_state.save_changes(context) + + # Assert + obj = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello", obj["property-a"]) + self.assertEqual("world", obj["property-b"]) + + # Act 2 + user_state2 = UserState(MemoryStorage(dictionary)) + + property_a2 = user_state2.create_property("property-a") + property_b2 = user_state2.create_property("property-b") + + await user_state2.load(context) + await property_a.set(context, "hello-2") + await property_b.set(context, "world-2") + await user_state2.save_changes(context) + + # Assert 2 + obj2 = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello-2", obj2["property-a"]) + self.assertEqual("world-2", obj2["property-b"]) + self.assertEqual("test", obj2["property-c"]) + + + async def test_LoadSaveDelete(self): + # Arrange + dictionary = {} + context = TestUtilities.create_empty_context() + + # Act + user_state = UserState(MemoryStorage(dictionary)) + + property_a = user_state.create_property("property-a") + property_b = user_state.create_property("property-b") + + await user_state.load(context) + await property_a.set(context, "hello") + await property_b.set(context, "world") + await user_state.save_changes(context) + + # Assert + obj = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello", obj["property-a"]) + self.assertEqual("world", obj["property-b"]) + + # Act 2 + user_state2 = UserState(MemoryStorage(dictionary)) + + property_a2 = user_state2.create_property("property-a") + property_b2 = user_state2.create_property("property-b") + + await user_state2.load(context) + await property_a.set(context, "hello-2") + await property_b.delete(context) + await user_state2.save_changes(context) + + # Assert 2 + obj2 = dictionary["EmptyContext/users/empty@empty.context.org"] + self.assertEqual("hello-2", obj2["property-a"]) + with self.assertRaises(KeyError) as _: + obj2["property-b"] \ No newline at end of file From 257a9479825f0a77d2bb723a75f92a0e76f315d1 Mon Sep 17 00:00:00 2001 From: congysu Date: Tue, 23 Apr 2019 15:27:48 -0700 Subject: [PATCH 36/73] add LuisApplication --- .../botbuilder-ai/botbuilder/ai/__init__.py | 0 .../botbuilder/ai/luis/__init__.py | 6 + .../botbuilder/ai/luis/luis_application.py | 160 ++++++++++++++++++ .../ai/luis/luis_prediction_options.py | 0 .../botbuilder/ai/luis/luis_recognizer.py | 0 .../ai/luis/luis_telemetry_constants.py | 0 .../botbuilder/ai/luis/luis_util.py | 0 libraries/botbuilder-ai/tests/__init__.py | 0 .../botbuilder-ai/tests/luis/__init__.py | 0 .../tests/luis/luis_application_test.py | 75 ++++++++ 10 files changed, 241 insertions(+) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/__init__.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_application.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_telemetry_constants.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py create mode 100644 libraries/botbuilder-ai/tests/__init__.py create mode 100644 libraries/botbuilder-ai/tests/luis/__init__.py create mode 100644 libraries/botbuilder-ai/tests/luis/luis_application_test.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py new file mode 100644 index 000000000..f4b9e1dd8 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .luis_application import LuisApplication + +__all__ = ["LuisApplication"] diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_application.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_application.py new file mode 100644 index 000000000..4f77cb5bd --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_application.py @@ -0,0 +1,160 @@ +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. + +from pathlib import PurePosixPath +from typing import Tuple +from urllib.parse import ParseResult, parse_qs, unquote, urlparse, urlunparse +from uuid import UUID, uuid4 + + +class LuisApplication(object): + """ + Data describing a LUIS application. + """ + + def __init__(self, application_id: str, endpoint_key: str, endpoint: str): + """Initializes a new instance of the class. + + :param application_id: LUIS application ID. + :type application_id: str + :param endpoint_key: LUIS subscription or endpoint key. + :type endpoint_key: str + :param endpoint: LUIS endpoint to use like https://westus.api.cognitive.microsoft.com. + :type endpoint: str + :raises ValueError: + :raises ValueError: + :raises ValueError: + """ + + _, valid = LuisApplication._try_parse_uuid4(application_id) + if not valid: + raise ValueError(f'"{application_id}" is not a valid LUIS application id.') + + _, valid = LuisApplication._try_parse_uuid4(endpoint_key) + if not valid: + raise ValueError(f'"{endpoint_key}" is not a valid LUIS subscription key.') + + if not endpoint or endpoint.isspace(): + endpoint = "https://westus.api.cognitive.microsoft.com" + + _, valid = LuisApplication._try_parse_url(endpoint) + if not valid: + raise ValueError(f'"{endpoint}" is not a valid LUIS endpoint.') + + self._application_id = application_id + self._endpoint_key = endpoint_key + self._endpoint = endpoint + + @classmethod + def from_application_endpoint(cls, application_endpoint: str): + """Initializes a new instance of the class. + + :param application_endpoint: LUIS application endpoint. + :type application_endpoint: str + :return: + :rtype: LuisApplication + """ + (application_id, endpoint_key, endpoint) = LuisApplication._parse( + application_endpoint + ) + return cls(application_id, endpoint_key, endpoint) + + @property + def application_id(self) -> str: + """Gets LUIS application ID. + + :return: LUIS application ID. + :rtype: str + """ + + return self._application_id + + @application_id.setter + def application_id(self, value: str) -> None: + """Sets LUIS application ID. + + :param value: LUIS application ID. + :type value: str + :return: + :rtype: None + """ + + self._application_id = value + + @property + def endpoint_key(self) -> str: + """Gets LUIS subscription or endpoint key. + + :return: LUIS subscription or endpoint key. + :rtype: str + """ + + return self._endpoint_key + + @endpoint_key.setter + def endpoint_key(self, value: str) -> None: + """Sets LUIS subscription or endpoint key. + + :param value: LUIS subscription or endpoint key. + :type value: str + :return: + :rtype: None + """ + + self._endpoint_key = value + + @property + def endpoint(self) -> str: + """Gets LUIS endpoint like https://westus.api.cognitive.microsoft.com. + + :return: LUIS endpoint where application is hosted. + :rtype: str + """ + + return self._endpoint + + @endpoint.setter + def endpoint(self, value: str) -> None: + """Sets LUIS endpoint like https://westus.api.cognitive.microsoft.com. + + :param value: LUIS endpoint where application is hosted. + :type value: str + :return: + :rtype: None + """ + + self._endpoint = value + + @staticmethod + def _parse(application_endpoint: str) -> Tuple[str, str, str]: + url, valid = LuisApplication._try_parse_url(application_endpoint) + if not valid: + raise ValueError( + f"{application_endpoint} is not a valid LUIS application endpoint." + ) + + segments = PurePosixPath(unquote(url.path)).parts + application_id = segments[-1] if segments else None + qs_parsed_result = parse_qs(url.query) + endpoint_key = qs_parsed_result.get("subscription-key", [None])[0] + + parts_for_base_url = url.scheme, url.netloc, "", None, None, None + endpoint = urlunparse(parts_for_base_url) + return (application_id, endpoint_key, endpoint) + + @staticmethod + def _try_parse_uuid4(uuid_string: str) -> Tuple[uuid4, bool]: + try: + uuid = UUID(uuid_string, version=4) + except (TypeError, ValueError): + return None, False + + return uuid, True + + @staticmethod + def _try_parse_url(url: str) -> Tuple[ParseResult, bool]: + try: + result = urlparse(url) + return result, True + except ValueError: + return None, False diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_telemetry_constants.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_telemetry_constants.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-ai/tests/__init__.py b/libraries/botbuilder-ai/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-ai/tests/luis/__init__.py b/libraries/botbuilder-ai/tests/luis/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-ai/tests/luis/luis_application_test.py b/libraries/botbuilder-ai/tests/luis/luis_application_test.py new file mode 100644 index 000000000..7f810850a --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/luis_application_test.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import unittest +from typing import List, Tuple +from uuid import uuid4 + +from botbuilder.ai.luis import LuisApplication + + +class LuisApplicationTest(unittest.TestCase): + endpoint: str = "https://westus.api.cognitive.microsoft.com" + + def test_luis_application_construction(self) -> None: + model = LuisApplicationTest.get_valid_model() + self.assertIsNotNone(model) + + construction_data: List[Tuple[str, str]] = [ + (None, str(uuid4())), + ("", str(uuid4())), + ("0000", str(uuid4())), + (str(uuid4()), None), + (str(uuid4()), ""), + (str(uuid4()), "000"), + ] + + for app_id, key in construction_data: + with self.subTest(app_id=app_id, key=key): + with self.assertRaises(ValueError): + LuisApplication(app_id, key, LuisApplicationTest.endpoint) + + luisApp = LuisApplication( + str(uuid4()), str(uuid4()), LuisApplicationTest.endpoint + ) + self.assertEqual(LuisApplicationTest.endpoint, luisApp.endpoint) + + @unittest.skip("revisit") + def test_luis_application_serialization(self) -> None: + model = LuisApplicationTest.get_valid_model() + serialized = json.dumps(model) + deserialized = json.loads(serialized) + + self.assertIsNotNone(deserialized) + self.assertEqual(model, deserialized) + + def test_list_application_from_luis_endpoint(self) -> None: + # Arrange + # Note this is NOT a real LUIS application ID nor a real LUIS subscription-key + # theses are GUIDs edited to look right to the parsing and validation code. + endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q=" + + # Act + app = LuisApplication.from_application_endpoint(endpoint) + + # Assert + self.assertEqual("b31aeaf3-3511-495b-a07f-571fc873214b", app.application_id) + self.assertEqual("048ec46dc58e495482b0c447cfdbd291", app.endpoint_key) + self.assertEqual("https://westus.api.cognitive.microsoft.com", app.endpoint) + + def test_list_application_from_luis_endpoint_bad_arguments(self) -> None: + application_endpoint_data: List[str] = [ + "this.is.not.a.uri", + "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&q=", + "https://westus.api.cognitive.microsoft.com?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q=", + ] + + for application_endpoint in application_endpoint_data: + with self.subTest(application_endpoint=application_endpoint): + with self.assertRaises(ValueError): + LuisApplication.from_application_endpoint(application_endpoint) + + @staticmethod + def get_valid_model() -> LuisApplication: + return LuisApplication(str(uuid4()), str(uuid4()), LuisApplicationTest.endpoint) From be41dc0a8d99bd1898dcbf589084005f9e90d1ca Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 23 Apr 2019 23:05:38 -0700 Subject: [PATCH 37/73] dialog classes for core bot (last details pending) --- .../botbuilder/core/activity_handler.py | 4 +- libraries/botbuilder-core/setup.py | 1 + samples/Core-Bot/bots/__init__.py | 4 + .../Core-Bot/bots/dialog_and_welcome_bot.py | 43 ++++ samples/Core-Bot/bots/dialog_bot.py | 29 +++ .../Core-Bot/bots/resources/welcomeCard.json | 46 ++++ .../cognitiveModels/FlightBooking.json | 226 ++++++++++++++++++ samples/Core-Bot/main.py | 93 +++++++ 8 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 samples/Core-Bot/bots/__init__.py create mode 100644 samples/Core-Bot/bots/dialog_and_welcome_bot.py create mode 100644 samples/Core-Bot/bots/dialog_bot.py create mode 100644 samples/Core-Bot/bots/resources/welcomeCard.json create mode 100644 samples/Core-Bot/cognitiveModels/FlightBooking.json create mode 100644 samples/Core-Bot/main.py diff --git a/libraries/botbuilder-core/botbuilder/core/activity_handler.py b/libraries/botbuilder-core/botbuilder/core/activity_handler.py index 4ddeab162..dc038fc84 100644 --- a/libraries/botbuilder-core/botbuilder/core/activity_handler.py +++ b/libraries/botbuilder-core/botbuilder/core/activity_handler.py @@ -29,9 +29,9 @@ async def on_message_activity(self, turn_context: TurnContext): async def on_conversation_update_activity(self, turn_context: TurnContext): if turn_context.activity.members_added is not None and len(turn_context.activity.members_added) > 0: - return await self.on_members_added_activity(turn_context) + return await self.on_members_added_activity(turn_context.activity.members_added, turn_context) elif turn_context.activity.members_removed is not None and len(turn_context.activity.members_removed) > 0: - return await self.on_members_removed_activity(turn_context) + return await self.on_members_removed_activity(turn_context.activity.members_removed, turn_context) return async def on_members_added_activity(self, members_added: ChannelAccount, turn_context: TurnContext): diff --git a/libraries/botbuilder-core/setup.py b/libraries/botbuilder-core/setup.py index d04d82813..087e772b8 100644 --- a/libraries/botbuilder-core/setup.py +++ b/libraries/botbuilder-core/setup.py @@ -4,6 +4,7 @@ import os from setuptools import setup +VERSION = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.0.0.a6" REQUIRES = [ 'botbuilder-schema>=4.0.0.a6', 'botframework-connector>=4.0.0.a6'] diff --git a/samples/Core-Bot/bots/__init__.py b/samples/Core-Bot/bots/__init__.py new file mode 100644 index 000000000..8c599914d --- /dev/null +++ b/samples/Core-Bot/bots/__init__.py @@ -0,0 +1,4 @@ +from .dialog_bot import DialogBot + +__all__ = [ + 'DialogBot'] \ No newline at end of file diff --git a/samples/Core-Bot/bots/dialog_and_welcome_bot.py b/samples/Core-Bot/bots/dialog_and_welcome_bot.py new file mode 100644 index 000000000..aa2426257 --- /dev/null +++ b/samples/Core-Bot/bots/dialog_and_welcome_bot.py @@ -0,0 +1,43 @@ +import json + +from typing import List +from botbuilder.core import CardFactory +from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext +from botbuilder.dialogs import Dialog +from botbuilder.schema import Activity, Attachment, ChannelAccount + +from .dialog_bot import DialogBot + +class DialogAndWelcomeBot(DialogBot): + + def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog): + super(DialogAndWelcomeBot, self).__init__(conversation_state, user_state, dialog) + + async def on_members_added_activity(self, members_added: List[ChannelAccount], turn_context: TurnContext): + for member in members_added: + # Greet anyone that was not the target (recipient) of this message. + # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. + if member.id != turn_context.activity.recipient.id: + welcome_card = CreateAdaptiveCardAttachment() + response = CreateResponse(turn_context.activity, welcome_card) + await turn_context.send_activity(response) + + # Create an attachment message response. + def create_response(self, activity: Activity, attachment: Attachment): + response = ((Activity)activity).CreateReply() + response.Attachments = new List() { attachment } + return response + + # Load attachment from file. + def create_adaptive_card_attachment(self): + { + # combine path for cross platform support + string[] paths = { ".", "Cards", "welcomeCard.json" }; + string fullPath = Path.Combine(paths); + var adaptiveCard = File.ReadAllText(fullPath); + return new Attachment() + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = JsonConvert.DeserializeObject(adaptiveCard), + }; + } \ No newline at end of file diff --git a/samples/Core-Bot/bots/dialog_bot.py b/samples/Core-Bot/bots/dialog_bot.py new file mode 100644 index 000000000..ae55579d8 --- /dev/null +++ b/samples/Core-Bot/bots/dialog_bot.py @@ -0,0 +1,29 @@ +import asyncio + +from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext +from botbuilder.dialogs import Dialog + +class DialogBot(ActivityHandler): + + def __init__(self, conversation_state: ConversationState, user_state: UserState, dialog: Dialog): + if conversation_state is None: + raise Exception('[DialogBot]: Missing parameter. conversation_state is required') + if user_state is None: + raise Exception('[DialogBot]: Missing parameter. user_state is required') + if dialog is None: + raise Exception('[DialogBot]: Missing parameter. dialog is required') + + self.conversation_state = conversation_state + self.user_state = user_state + self.dialog = dialog + self.dialogState = self.conversation_state.create_property('DialogState') + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + # Save any state changes that might have occured during the turn. + await self.conversation_state.save_changes(turn_context, False) + await self.user_state.save_changes(turn_context, False) + + async def on_message_activity(self, turn_context: TurnContext): + await self.dialog.run(turn_context, self.conversation_state.create_property("DialogState")) \ No newline at end of file diff --git a/samples/Core-Bot/bots/resources/welcomeCard.json b/samples/Core-Bot/bots/resources/welcomeCard.json new file mode 100644 index 000000000..b6b5f1828 --- /dev/null +++ b/samples/Core-Bot/bots/resources/welcomeCard.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "Image", + "url": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQtB3AwMUeNoq4gUBGe6Ocj8kyh3bXa9ZbV7u1fVKQoyKFHdkqU", + "size": "stretch" + }, + { + "type": "TextBlock", + "spacing": "medium", + "size": "default", + "weight": "bolder", + "text": "Welcome to Bot Framework!", + "wrap": true, + "maxLines": 0 + }, + { + "type": "TextBlock", + "size": "default", + "isSubtle": "yes", + "text": "Now that you have successfully run your bot, follow the links in this Adaptive Card to expand your knowledge of Bot Framework.", + "wrap": true, + "maxLines": 0 + } + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "Get an overview", + "url": "https://docs.microsoft.com/en-us/azure/bot-service/?view=azure-bot-service-4.0" + }, + { + "type": "Action.OpenUrl", + "title": "Ask a question", + "url": "https://stackoverflow.com/questions/tagged/botframework" + }, + { + "type": "Action.OpenUrl", + "title": "Learn how to deploy", + "url": "https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" + } + ] +} \ No newline at end of file diff --git a/samples/Core-Bot/cognitiveModels/FlightBooking.json b/samples/Core-Bot/cognitiveModels/FlightBooking.json new file mode 100644 index 000000000..0a0d6c4a7 --- /dev/null +++ b/samples/Core-Bot/cognitiveModels/FlightBooking.json @@ -0,0 +1,226 @@ +{ + "luis_schema_version": "3.2.0", + "versionId": "0.1", + "name": "Airline Reservation", + "desc": "A LUIS model that uses intent and entities.", + "culture": "en-us", + "tokenizerVersion": "1.0.0", + "intents": [ + { + "name": "Book flight" + }, + { + "name": "Cancel" + }, + { + "name": "None" + } + ], + "entities": [], + "composites": [ + { + "name": "From", + "children": [ + "Airport" + ], + "roles": [] + }, + { + "name": "To", + "children": [ + "Airport" + ], + "roles": [] + } + ], + "closedLists": [ + { + "name": "Airport", + "subLists": [ + { + "canonicalForm": "Paris", + "list": [ + "paris" + ] + }, + { + "canonicalForm": "London", + "list": [ + "london" + ] + }, + { + "canonicalForm": "Berlin", + "list": [ + "berlin" + ] + }, + { + "canonicalForm": "New York", + "list": [ + "new york" + ] + } + ], + "roles": [] + } + ], + "patternAnyEntities": [], + "regex_entities": [], + "prebuiltEntities": [ + { + "name": "datetimeV2", + "roles": [] + } + ], + "model_features": [], + "regex_features": [], + "patterns": [], + "utterances": [ + { + "text": "book flight from london to paris on feb 14th", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 27, + "endPos": 31 + }, + { + "entity": "From", + "startPos": 17, + "endPos": 22 + } + ] + }, + { + "text": "book flight to berlin on feb 14th", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 15, + "endPos": 20 + } + ] + }, + { + "text": "book me a flight from london to paris", + "intent": "Book flight", + "entities": [ + { + "entity": "From", + "startPos": 22, + "endPos": 27 + }, + { + "entity": "To", + "startPos": 32, + "endPos": 36 + } + ] + }, + { + "text": "bye", + "intent": "Cancel", + "entities": [] + }, + { + "text": "cancel booking", + "intent": "Cancel", + "entities": [] + }, + { + "text": "exit", + "intent": "Cancel", + "entities": [] + }, + { + "text": "flight to paris", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "flight to paris from london on feb 14th", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + }, + { + "entity": "From", + "startPos": 21, + "endPos": 26 + } + ] + }, + { + "text": "fly from berlin to paris on may 5th", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 19, + "endPos": 23 + }, + { + "entity": "From", + "startPos": 9, + "endPos": 14 + } + ] + }, + { + "text": "go to paris", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 6, + "endPos": 10 + } + ] + }, + { + "text": "going from paris to berlin", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 20, + "endPos": 25 + }, + { + "entity": "From", + "startPos": 11, + "endPos": 15 + } + ] + }, + { + "text": "ignore", + "intent": "Cancel", + "entities": [] + }, + { + "text": "travel to paris", + "intent": "Book flight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + } + ], + "settings": [] +} \ No newline at end of file diff --git a/samples/Core-Bot/main.py b/samples/Core-Bot/main.py new file mode 100644 index 000000000..e966076cb --- /dev/null +++ b/samples/Core-Bot/main.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +This sample shows how to create a simple EchoBot with state. +""" + + +from aiohttp import web +from botbuilder.schema import (Activity, ActivityTypes) +from botbuilder.core import (BotFrameworkAdapter, BotFrameworkAdapterSettings, TurnContext, + ConversationState, MemoryStorage, UserState) + +APP_ID = '' +APP_PASSWORD = '' +PORT = 9000 +SETTINGS = BotFrameworkAdapterSettings(APP_ID, APP_PASSWORD) +ADAPTER = BotFrameworkAdapter(SETTINGS) + +# Create MemoryStorage, UserState and ConversationState +memory = MemoryStorage() +# Commented out user_state because it's not being used. +# user_state = UserState(memory) +conversation_state = ConversationState(memory) + +# Register both State middleware on the adapter. +# Commented out user_state because it's not being used. +# ADAPTER.use(user_state) +ADAPTER.use(conversation_state) + + +async def create_reply_activity(request_activity, text) -> Activity: + return Activity( + type=ActivityTypes.message, + channel_id=request_activity.channel_id, + conversation=request_activity.conversation, + recipient=request_activity.from_property, + from_property=request_activity.recipient, + text=text, + service_url=request_activity.service_url) + + +async def handle_message(context: TurnContext) -> web.Response: + # Access the state for the conversation between the user and the bot. + state = await conversation_state.get(context) + + if hasattr(state, 'counter'): + state.counter += 1 + else: + state.counter = 1 + + response = await create_reply_activity(context.activity, f'{state.counter}: You said {context.activity.text}.') + await context.send_activity(response) + return web.Response(status=202) + + +async def handle_conversation_update(context: TurnContext) -> web.Response: + if context.activity.members_added[0].id != context.activity.recipient.id: + response = await create_reply_activity(context.activity, 'Welcome to the Echo Adapter Bot!') + await context.send_activity(response) + return web.Response(status=200) + + +async def unhandled_activity() -> web.Response: + return web.Response(status=404) + + +async def request_handler(context: TurnContext) -> web.Response: + if context.activity.type == 'message': + return await handle_message(context) + elif context.activity.type == 'conversationUpdate': + return await handle_conversation_update(context) + else: + return await unhandled_activity() + + +async def messages(req: web.web_request) -> web.Response: + body = await req.json() + activity = Activity().deserialize(body) + auth_header = req.headers['Authorization'] if 'Authorization' in req.headers else '' + try: + return await ADAPTER.process_activity(activity, auth_header, request_handler) + except Exception as e: + raise e + + +app = web.Application() +app.router.add_post('/', messages) + +try: + web.run_app(app, host='localhost', port=PORT) +except Exception as e: + raise e From 81ab00ba5883324930949cbab39d4bb5a4763de1 Mon Sep 17 00:00:00 2001 From: congysu Date: Wed, 24 Apr 2019 13:28:04 -0700 Subject: [PATCH 38/73] add luis prediction options --- .../ai/luis/luis_prediction_options.py | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py index e69de29bb..68ea803c9 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py @@ -0,0 +1,243 @@ +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. + +from botbuilder.core import BotTelemetryClient, NullTelemetryClient + + +class LuisPredictionOptions(object): + """ + Optional parameters for a LUIS prediction request. + """ + + def __init__(self): + self._bing_spell_check_subscription_key: str = None + self._include_all_intents: bool = None + self._include_instance_data: bool = None + self._log: bool = None + self._spell_check: bool = None + self._staging: bool = None + self._timeout: float = 100000 + self._timezone_offset: float = None + self._telemetry_client: BotTelemetryClient = NullTelemetryClient() + self._log_personal_information: bool = False + + @property + def bing_spell_check_subscription_key(self) -> str: + """Gets or sets the Bing Spell Check subscription key. + + :return: The Bing Spell Check subscription key. + :rtype: str + """ + + return self._bing_spell_check_subscription_key + + @bing_spell_check_subscription_key.setter + def bing_spell_check_subscription_key(self, value: str) -> None: + """Gets or sets the Bing Spell Check subscription key. + + :param value: The Bing Spell Check subscription key. + :type value: str + :return: + :rtype: None + """ + + self._bing_spell_check_subscription_key = value + + @property + def include_all_intents(self) -> bool: + """Gets or sets whether all intents come back or only the top one. + + :return: True for returning all intents. + :rtype: bool + """ + + return self._include_all_intents + + @include_all_intents.setter + def include_all_intents(self, value: bool) -> None: + """Gets or sets whether all intents come back or only the top one. + + :param value: True for returning all intents. + :type value: bool + :return: + :rtype: None + """ + + self._include_all_intents = value + + @property + def include_instance_data(self) -> bool: + """Gets or sets a value indicating whether or not instance data should be included in response. + + :return: A value indicating whether or not instance data should be included in response. + :rtype: bool + """ + + return self._include_instance_data + + @include_instance_data.setter + def include_instance_data(self, value: bool) -> None: + """Gets or sets a value indicating whether or not instance data should be included in response. + + :param value: A value indicating whether or not instance data should be included in response. + :type value: bool + :return: + :rtype: None + """ + + self._include_instance_data = value + + @property + def log(self) -> bool: + """Gets or sets if queries should be logged in LUIS. + + :return: If queries should be logged in LUIS. + :rtype: bool + """ + + return self._log + + @log.setter + def log(self, value: bool) -> None: + """Gets or sets if queries should be logged in LUIS. + + :param value: If queries should be logged in LUIS. + :type value: bool + :return: + :rtype: None + """ + + self._log = value + + @property + def spell_check(self) -> bool: + """Gets or sets whether to spell check queries. + + :return: Whether to spell check queries. + :rtype: bool + """ + + return self._spell_check + + @spell_check.setter + def spell_check(self, value: bool) -> None: + """Gets or sets whether to spell check queries. + + :param value: Whether to spell check queries. + :type value: bool + :return: + :rtype: None + """ + + self._spell_check = value + + @property + def staging(self) -> bool: + """Gets or sets whether to use the staging endpoint. + + :return: Whether to use the staging endpoint. + :rtype: bool + """ + + return self._staging + + @staging.setter + def staging(self, value: bool) -> None: + """Gets or sets whether to use the staging endpoint. + + + :param value: Whether to use the staging endpoint. + :type value: bool + :return: + :rtype: None + """ + + self._staging = value + + @property + def timeout(self) -> float: + """Gets or sets the time in milliseconds to wait before the request times out. + + :return: The time in milliseconds to wait before the request times out. Default is 100000 milliseconds. + :rtype: float + """ + + return self._timeout + + @timeout.setter + def timeout(self, value: float) -> None: + """Gets or sets the time in milliseconds to wait before the request times out. + + :param value: The time in milliseconds to wait before the request times out. Default is 100000 milliseconds. + :type value: float + :return: + :rtype: None + """ + + self._timeout = value + + @property + def timezone_offset(self) -> float: + """Gets or sets the time zone offset. + + :return: The time zone offset. + :rtype: float + """ + + return self._timezone_offset + + @timezone_offset.setter + def timezone_offset(self, value: float) -> None: + """Gets or sets the time zone offset. + + :param value: The time zone offset. + :type value: float + :return: + :rtype: None + """ + + self._timezone_offset = value + + @property + def telemetry_client(self) -> BotTelemetryClient: + """Gets or sets the IBotTelemetryClient used to log the LuisResult event. + + :return: The client used to log telemetry events. + :rtype: BotTelemetryClient + """ + + return self._telemetry_client + + @telemetry_client.setter + def telemetry_client(self, value: BotTelemetryClient) -> None: + """Gets or sets the IBotTelemetryClient used to log the LuisResult event. + + :param value: The client used to log telemetry events. + :type value: BotTelemetryClient + :return: + :rtype: None + """ + + self._telemetry_client = value + + @property + def log_personal_information(self) -> bool: + """Gets or sets a value indicating whether to log personal information that came from the user to telemetry. + + :return: If true, personal information is logged to Telemetry; otherwise the properties will be filtered. + :rtype: bool + """ + + return self._log_personal_information + + @log_personal_information.setter + def log_personal_information(self, value: bool) -> None: + """Gets or sets a value indicating whether to log personal information that came from the user to telemetry. + + :param value: If true, personal information is logged to Telemetry; otherwise the properties will be filtered. + :type value: bool + :return: + :rtype: None + """ + + self.log_personal_information = value From ad1a1a82db1421c921782f3bf9c0c61f8211428d Mon Sep 17 00:00:00 2001 From: congysu Date: Wed, 24 Apr 2019 13:38:19 -0700 Subject: [PATCH 39/73] add luis telemetry constants --- .../ai/luis/luis_telemetry_constants.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_telemetry_constants.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_telemetry_constants.py index e69de29bb..6bab0d189 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_telemetry_constants.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_telemetry_constants.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum + + +class LuisTelemetryConstants(str, Enum): + """ + The IBotTelemetryClient event and property names that logged by default. + """ + + luis_result = "LuisResult" + """Event name""" + application_id_property = "applicationId" + intent_property = "intent" + intent_score_property = "intentScore" + intent2_property = "intent2" + intent_score2_property = "intentScore2" + entities_property = "entities" + question_property = "question" + activity_id_property = "activityId" + sentiment_label_property = "sentimentLabel" + sentiment_score_property = "sentimentScore" + from_id_property = "fromId" From f5a20e97abc15db7d0dd86a3880e6544131dbac9 Mon Sep 17 00:00:00 2001 From: congysu Date: Wed, 24 Apr 2019 16:36:24 -0700 Subject: [PATCH 40/73] add recognizer result etc. * add recognizer result * add intent score * add luis util * add intent score --- .../botbuilder/ai/luis/__init__.py | 4 +- .../botbuilder/ai/luis/intent_score.py | 58 ++++ .../botbuilder/ai/luis/luis_util.py | 288 ++++++++++++++++++ .../botbuilder/ai/luis/recognizer_result.py | 132 ++++++++ libraries/botbuilder-ai/requirements.txt | 9 + 5 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/intent_score.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py create mode 100644 libraries/botbuilder-ai/requirements.txt diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py index f4b9e1dd8..979b1adff 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from .intent_score import IntentScore from .luis_application import LuisApplication +from .recognizer_result import RecognizerResult -__all__ = ["LuisApplication"] +__all__ = ["IntentScore", "LuisApplication", "RecognizerResult"] diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/intent_score.py b/libraries/botbuilder-ai/botbuilder/ai/luis/intent_score.py new file mode 100644 index 000000000..7e175046e --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/intent_score.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict + + +class IntentScore(object): + """ + Score plus any extra information about an intent. + """ + + def __init__(self, score: float = None, properties: Dict[str, object] = {}): + self._score: float = score + self._properties: Dict[str, object] = properties + + @property + def score(self) -> float: + """Gets confidence in an intent. + + :return: Confidence in an intent. + :rtype: float + """ + + return self._score + + @score.setter + def score(self, value: float) -> None: + """Sets confidence in an intent. + + :param value: Confidence in an intent. + :type value: float + :return: + :rtype: None + """ + + self._score = value + + @property + def properties(self) -> Dict[str, object]: + """Gets any extra properties to include in the results. + + :return: Any extra properties to include in the results. + :rtype: Dict[str, object] + """ + + return self._properties + + @properties.setter + def properties(self, value: Dict[str, object]) -> None: + """Sets any extra properties to include in the results. + + :param value: Any extra properties to include in the results. + :type value: Dict[str, object] + :return: + :rtype: None + """ + + self._properties = value diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index e69de29bb..9eec635de 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -0,0 +1,288 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict, List, Set, Union + +from azure.cognitiveservices.language.luis.runtime.models import ( + CompositeEntityModel, + EntityModel, + LuisResult, +) + +from . import IntentScore, RecognizerResult + + +class LuisUtil: + """ + Utility functions used to extract and transform data from Luis SDK + """ + + _metadataKey: str = "$instance" + + @staticmethod + def normalized_intent(intent: str) -> str: + return intent.replace(".", "_").replace(" ", "_") + + @staticmethod + def get_intents(luisResult: LuisResult) -> Dict[str, IntentScore]: + if luisResult.intents: + return { + LuisUtil.normalized_intent(i.intent): IntentScore(i.score or 0) + for i in luisResult.intents + } + else: + return { + LuisUtil.normalized_intent( + luisResult.top_scoring_intent.intent + ): IntentScore(luisResult.top_scoring_intent.score or 0) + } + + @staticmethod + def extract_entities_and_metadata( + entities: List[EntityModel], + compositeEntities: List[CompositeEntityModel], + verbose: bool, + ) -> Dict: + entitiesAndMetadata = {} + if verbose: + entitiesAndMetadata[LuisUtil._metadataKey] = {} + + compositeEntityTypes = set() + + # We start by populating composite entities so that entities covered by them are removed from the entities list + if compositeEntities: + compositeEntityTypes = set(ce.parent_type for ce in compositeEntities) + current = entities + for compositeEntity in compositeEntities: + current = LuisUtil.populate_composite_entity_model( + compositeEntity, current, entitiesAndMetadata, verbose + ) + entities = current + + for entity in entities: + # we'll address composite entities separately + if entity.type in compositeEntityTypes: + continue + + LuisUtil.add_property( + entitiesAndMetadata, + LuisUtil.extract_normalized_entity_name(entity), + LuisUtil.extract_entity_value(entity), + ) + + if verbose: + LuisUtil.add_property( + entitiesAndMetadata[LuisUtil._metadataKey], + LuisUtil.extract_normalized_entity_name(entity), + LuisUtil.extract_entity_metadata(entity), + ) + + return entitiesAndMetadata + + @staticmethod + def number(value: object) -> Union(int, float): + if value is None: + return None + + try: + s = str(value) + i = int(s) + return i + except ValueError: + f = float(s) + return f + + @staticmethod + def extract_entity_value(entity: EntityModel) -> object: + if ( + entity.AdditionalProperties is None + or "resolution" not in entity.AdditionalProperties + ): + return entity.entity + + resolution = entity.AdditionalProperty["resolution"] + if entity.Type.startswith("builtin.datetime."): + return resolution + elif entity.Type.startswith("builtin.datetimeV2."): + if not resolution.values: + return resolution + + resolutionValues = resolution.values + val_type = resolution.values[0].type + timexes = [val.timex for val in resolutionValues] + distinctTimexes = list(set(timexes)) + return {"type": val_type, "timex": distinctTimexes} + else: + if entity.type in {"builtin.number", "builtin.ordinal"}: + return LuisUtil.number(resolution.value) + elif entity.type == "builtin.percentage": + svalue = str(resolution.value) + if svalue.endswith("%"): + svalue = svalue[:-1] + + return LuisUtil.number(svalue) + elif entity.type in { + "builtin.age", + "builtin.dimension", + "builtin.currency", + "builtin.temperature", + }: + units = str(resolution.unit) + val = LuisUtil.number(resolution.value) + obj = {} + if val is not None: + obj["number"] = val + + obj["units"] = units + return obj + + else: + return resolution.value or resolution.values + + @staticmethod + def extract_entity_metadata(entity: EntityModel) -> Dict: + obj = dict( + startIndex=int(entity.start_index), + endIndex=int(entity.end_index + 1), + text=entity.entity, + type=entity.type, + ) + + if entity.AdditionalProperties is not None: + if "score" in entity.AdditionalProperties: + obj["score"] = float(entity.AdditionalProperties["score"]) + + resolution = entity.AdditionalProperties.get("resolution") + if resolution is not None and resolution.subtype is not None: + obj["subtype"] = resolution.subtype + + return obj + + @staticmethod + def extract_normalized_entity_name(entity: EntityModel) -> str: + # Type::Role -> Role + type = entity.Type.split(":")[-1] + if type.startswith("builtin.datetimeV2."): + type = "datetime" + + if type.startswith("builtin.currency"): + type = "money" + + if type.startswith("builtin."): + type = type[8:] + + role = ( + entity.AdditionalProperties["role"] + if entity.AdditionalProperties is not None + and "role" in entity.AdditionalProperties + else "" + ) + if role and not role.isspace(): + type = role + + return type.replace(".", "_").replace(" ", "_") + + @staticmethod + def populate_composite_entity_model( + compositeEntity: CompositeEntityModel, + entities: List[EntityModel], + entitiesAndMetadata: Dict, + verbose: bool, + ) -> List[EntityModel]: + childrenEntites = {} + childrenEntitiesMetadata = {} + if verbose: + childrenEntites[LuisUtil._metadataKey] = {} + + # This is now implemented as O(n^2) search and can be reduced to O(2n) using a map as an optimization if n grows + compositeEntityMetadata = next( + ( + e + for e in entities + if e.type == compositeEntity.parent_type + and e.entity == compositeEntity.value + ), + None, + ) + + # This is an error case and should not happen in theory + if compositeEntityMetadata is None: + return entities + + if verbose: + childrenEntitiesMetadata = LuisUtil.extract_entity_metadata( + compositeEntityMetadata + ) + childrenEntites[LuisUtil._metadataKey] = {} + + coveredSet: Set[EntityModel] = set() + for child in compositeEntity.Children: + for entity in entities: + # We already covered this entity + if entity in coveredSet: + continue + + # This entity doesn't belong to this composite entity + if child.Type != entity.Type or not LuisUtil.composite_contains_entity( + compositeEntityMetadata, entity + ): + continue + + # Add to the set to ensure that we don't consider the same child entity more than once per composite + coveredSet.add(entity) + LuisUtil.add_property( + childrenEntites, + LuisUtil.extract_normalized_entity_name(entity), + LuisUtil.extract_entity_value(entity), + ) + + if verbose: + LuisUtil.add_property( + childrenEntites[LuisUtil._metadataKey], + LuisUtil.extract_normalized_entity_name(entity), + LuisUtil.extract_entity_metadata(entity), + ) + + LuisUtil.add_property( + entitiesAndMetadata, + LuisUtil.extract_normalized_entity_name(compositeEntityMetadata), + childrenEntites, + ) + if verbose: + LuisUtil.add_property( + entitiesAndMetadata[LuisUtil._metadataKey], + LuisUtil.extract_normalized_entity_name(compositeEntityMetadata), + childrenEntitiesMetadata, + ) + + # filter entities that were covered by this composite entity + return [entity for entity in entities if entity not in coveredSet] + + @staticmethod + def composite_contains_entity( + compositeEntityMetadata: EntityModel, entity: EntityModel + ) -> bool: + return ( + entity.StartIndex >= compositeEntityMetadata.StartIndex + and entity.EndIndex <= compositeEntityMetadata.EndIndex + ) + + @staticmethod + def add_property(obj: Dict[str, object], key: str, value: object) -> None: + # If a property doesn't exist add it to a new array, otherwise append it to the existing array. + + if key in obj: + obj[key].append(value) + else: + obj[key] = [value] + + @staticmethod + def add_properties(luis: LuisResult, result: RecognizerResult) -> None: + if luis.SentimentAnalysis is not None: + result.Properties.Add( + "sentiment", + { + "label": luis.SentimentAnalysis.Label, + "score": luis.SentimentAnalysis.Score, + }, + ) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py new file mode 100644 index 000000000..a6c732ff8 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py @@ -0,0 +1,132 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict + +from . import IntentScore + + +class RecognizerResult: + """ + Contains recognition results generated by a recognizer. + """ + + def __init__(self): + self._text: str = None + self._altered_text: str = None + self._intents: Dict[str, IntentScore] = None + self._entities: Dict = None + self._properties: Dict[str, object] = {} + + @property + def text(self) -> str: + """Gets the input text to recognize. + + :return: Original text to recognizer. + :rtype: str + """ + + return self._text + + @text.setter + def text(self, value: str) -> None: + """Sets the input text to recognize. + + :param value: Original text to recognizer. + :type value: str + :return: + :rtype: None + """ + + self._text = value + + @property + def altered_text(self) -> str: + """Gets the input text as modified by the recognizer, for example for spelling correction. + + :return: Text modified by recognizer. + :rtype: str + """ + + return self._altered_text + + @altered_text.setter + def altered_text(self, value: str) -> None: + """Sets the input text as modified by the recognizer, for example for spelling correction. + + :param value: Text modified by recognizer. + :type value: str + :return: + :rtype: None + """ + + self._altered_text = value + + @property + def intents(self) -> Dict[str, IntentScore]: + """Gets the recognized intents, with the intent as key and the confidence as value. + + :return: Mapping from intent to information about the intent. + :rtype: Dict[str, IntentScore] + """ + + return self._intents + + @intents.setter + def intents(self, value: Dict[str, IntentScore]) -> None: + """Sets the recognized intents, with the intent as key and the confidence as value. + + + :param value: Mapping from intent to information about the intent. + :type value: Dict[str, IntentScore] + :return: + :rtype: None + """ + + self._intents = value + + @property + def entities(self) -> Dict: + """Gets the recognized top-level entities. + + :return: Object with each top-level recognized entity as a key. + :rtype: Dict + """ + + return self._entities + + @entities.setter + def entities(self, value: Dict) -> None: + """Sets the recognized top-level entities. + + :param value: Object with each top-level recognized entity as a key. + :type value: Dict + :return: + :rtype: None + """ + + self._entities = value + + @property + def properties(self) -> Dict[str, object]: + """Gets properties that are not otherwise defined by the type but that + might appear in the REST JSON object. + + :return: The extended properties for the object. + :rtype: Dict[str, object] + """ + + return self._properties + + @properties.setter + def properties(self, value: Dict[str, object]) -> None: + """Sets properties that are not otherwise defined by the type but that + might appear in the REST JSON object. + + :param value: The extended properties for the object. + :type value: Dict[str, object] + :return: + :rtype: None + """ + + self._properties = value diff --git a/libraries/botbuilder-ai/requirements.txt b/libraries/botbuilder-ai/requirements.txt new file mode 100644 index 000000000..85ba5d03b --- /dev/null +++ b/libraries/botbuilder-ai/requirements.txt @@ -0,0 +1,9 @@ +#msrest>=0.6.6 +#botframework-connector>=4.0.0.a6 +#botbuilder-schema>=4.0.0.a6 +botbuilder-core>=4.0.0.a6 +#requests>=2.18.1 +#PyJWT==1.5.3 +#cryptography==2.1.4 +#aiounittest>=1.1.0 +azure-cognitiveservices-language-luis==0.1.0 \ No newline at end of file From 41b009a529a83cd1676a1b117acaf1f92b1dd7be Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 24 Apr 2019 22:57:06 -0700 Subject: [PATCH 41/73] dialogs of core bot done, luis_helper and main.py pending --- .../dialogs/prompts/confirm_prompt.py | 2 +- .../dialogs/prompts/datetime_resolution.py | 10 +-- .../dialogs/prompts/prompt_options.py | 14 +-- .../botbuilder/dialogs/prompts/text_prompt.py | 6 +- samples/Core-Bot/booking_details.py | 5 ++ .../Core-Bot/bots/dialog_and_welcome_bot.py | 26 +++--- samples/Core-Bot/dialogs/__init__.py | 10 +++ samples/Core-Bot/dialogs/booking_dialog.py | 89 +++++++++++++++++++ .../dialogs/cancel_and_help_dialog.py | 36 ++++++++ .../Core-Bot/dialogs/date_resolver_dialog.py | 52 +++++++++++ samples/Core-Bot/dialogs/main_dialog.py | 57 ++++++++++++ samples/Core-Bot/helpers/__init__.py | 4 + samples/Core-Bot/helpers/activity_helper.py | 19 ++++ 13 files changed, 299 insertions(+), 31 deletions(-) create mode 100644 samples/Core-Bot/booking_details.py create mode 100644 samples/Core-Bot/dialogs/__init__.py create mode 100644 samples/Core-Bot/dialogs/booking_dialog.py create mode 100644 samples/Core-Bot/dialogs/cancel_and_help_dialog.py create mode 100644 samples/Core-Bot/dialogs/date_resolver_dialog.py create mode 100644 samples/Core-Bot/dialogs/main_dialog.py create mode 100644 samples/Core-Bot/helpers/__init__.py create mode 100644 samples/Core-Bot/helpers/activity_helper.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py index 426b3f422..55a88059f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/confirm_prompt.py @@ -25,7 +25,7 @@ class ConfirmPrompt(Prompt): } # TODO: PromptValidator - def __init__(self, dialog_id: str, validator: object, default_locale: str): + def __init__(self, dialog_id: str, validator: object = None, default_locale: str = None): super(ConfirmPrompt, self).__init__(dialog_id, validator) if dialog_id is None: raise TypeError('ConfirmPrompt(): dialog_id cannot be None.') diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py index 3cd022eb6..e2505be27 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/datetime_resolution.py @@ -2,9 +2,9 @@ # Licensed under the MIT License. class DateTimeResolution: - def __init__(self): - self.value = None - self.start = None - self.end = None - self.timex = None + def __init__(self, value: str = None, start: str = None, end: str = None, timex: str = None): + self.value = value + self.start = start + self.end = end + self.timex = timex diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py index 4ebd911e7..0cabde791 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt_options.py @@ -6,15 +6,15 @@ class PromptOptions: - def __init__(self): - self._prompt: Activity = None - self._retry_prompt: Activity = None + def __init__(self, prompt: Activity = None, retry_prompt: Activity = None, choices: [] = None, style: object = None, validations: object = None, number_of_attempts: int = 0): + self._prompt: prompt + self._retry_prompt: retry_prompt # TODO: Replace with Choice Object once ported - self._choices: [] = None + self._choices: choices # TODO: Replace with ListStyle Object once ported - self._style: object = None - self._validations: object = None - self._number_of_attempts: int = 0 + self._style = style + self._validations = validations + self._number_of_attempts = validations @property def prompt(self) -> Activity: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py index 5cd9eb272..83dbbe350 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/text_prompt.py @@ -2,8 +2,8 @@ # Licensed under the MIT License. from typing import Dict -from botbuilder.core.turn_context import TurnContext -from botbuilder.schema import (ActivityTypes, Activity) +from botbuilder.core import TurnContext +from botbuilder.schema import ActivityTypes, Activity from .datetime_resolution import DateTimeResolution from .prompt import Prompt from .confirm_prompt import ConfirmPrompt @@ -13,7 +13,7 @@ class TextPrompt(Prompt): # TODO: PromptValidator - def __init__(self, dialog_id: str, validator: object): + def __init__(self, dialog_id: str, validator: object = None): super(TextPrompt, self).__init__(dialog_id, validator) async def on_prompt(self, turn_context: TurnContext, state: Dict[str, object], options: PromptOptions, is_retry: bool): diff --git a/samples/Core-Bot/booking_details.py b/samples/Core-Bot/booking_details.py new file mode 100644 index 000000000..1e2f2d77f --- /dev/null +++ b/samples/Core-Bot/booking_details.py @@ -0,0 +1,5 @@ +class BookingDetails: + def __init__(self, destination: str = None, origin: str = None, travel_date: str = None): + self.destination = destination + self.origin = origin + self.travel_date = travel_date \ No newline at end of file diff --git a/samples/Core-Bot/bots/dialog_and_welcome_bot.py b/samples/Core-Bot/bots/dialog_and_welcome_bot.py index aa2426257..053901291 100644 --- a/samples/Core-Bot/bots/dialog_and_welcome_bot.py +++ b/samples/Core-Bot/bots/dialog_and_welcome_bot.py @@ -5,6 +5,7 @@ from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext from botbuilder.dialogs import Dialog from botbuilder.schema import Activity, Attachment, ChannelAccount +from helpers.activity_helper import create_activity_reply from .dialog_bot import DialogBot @@ -18,26 +19,21 @@ async def on_members_added_activity(self, members_added: List[ChannelAccount], t # Greet anyone that was not the target (recipient) of this message. # To learn more about Adaptive Cards, see https://aka.ms/msbot-adaptivecards for more details. if member.id != turn_context.activity.recipient.id: - welcome_card = CreateAdaptiveCardAttachment() - response = CreateResponse(turn_context.activity, welcome_card) + welcome_card = self.create_adaptive_card_attachment() + response = self.create_response(turn_context.activity, welcome_card) await turn_context.send_activity(response) # Create an attachment message response. def create_response(self, activity: Activity, attachment: Attachment): - response = ((Activity)activity).CreateReply() - response.Attachments = new List() { attachment } + response = create_activity_reply(activity) + response.Attachments = [attachment] return response # Load attachment from file. def create_adaptive_card_attachment(self): - { - # combine path for cross platform support - string[] paths = { ".", "Cards", "welcomeCard.json" }; - string fullPath = Path.Combine(paths); - var adaptiveCard = File.ReadAllText(fullPath); - return new Attachment() - { - ContentType = "application/vnd.microsoft.card.adaptive", - Content = JsonConvert.DeserializeObject(adaptiveCard), - }; - } \ No newline at end of file + with open('resources/welcomeCard.json') as f: + card = json.load(f) + + return Attachment( + content_type= "application/vnd.microsoft.card.adaptive", + content= card) \ No newline at end of file diff --git a/samples/Core-Bot/dialogs/__init__.py b/samples/Core-Bot/dialogs/__init__.py new file mode 100644 index 000000000..7a227b177 --- /dev/null +++ b/samples/Core-Bot/dialogs/__init__.py @@ -0,0 +1,10 @@ +from .booking_dialog import BookingDialog +from .cancel_and_help_dialog import CancelAndHelpDialog +from .date_resolver_dialog import DateResolverDialog +from .main_dialog import MainDialog + +__all__ = [ + 'BookingDialog', + 'CancelAndHelpDialog', + 'DateResolverDialog', + 'MainDialog'] \ No newline at end of file diff --git a/samples/Core-Bot/dialogs/booking_dialog.py b/samples/Core-Bot/dialogs/booking_dialog.py new file mode 100644 index 000000000..1aa29079d --- /dev/null +++ b/samples/Core-Bot/dialogs/booking_dialog.py @@ -0,0 +1,89 @@ +from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult +from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions +from botbuilder.core import MessageFactory +from .cancel_and_help_dialog import CancelAndHelpDialog +from .date_resolver_dialog import DateResolverDialog + +class BookingDialog(CancelAndHelpDialog): + + def __init__(self, dialog_id: str = None): + super(BookingDialog, self).__init__(dialog_id or BookingDialog.__name__) + + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog(ConfirmPrompt(ConfirmPrompt.__name__)) + self.add_dialog(DateResolverDialog(DateResolverDialog.__name__)) + self.add_dialog(WaterfallDialog(WaterfallDialog.__name__, [ + + ])) + + self.initial_dialog_id(WaterfallDialog.__name__) + + """ + If a destination city has not been provided, prompt for one. + """ + async def destination_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + booking_details = step_context.options + + if (booking_details.destination is None): + return await step_context.prompt(TextPrompt.__name__, PromptOptions(prompt= MessageFactory.text('To what city would you like to travel?'))) + else: + return await step_context.next(booking_details.destination) + + """ + If an origin city has not been provided, prompt for one. + """ + async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + booking_details = step_context.options + + # Capture the response to the previous step's prompt + booking_details.destination = step_context.result + if (booking_details.origin is None): + return await step_context.prompt(TextPrompt.__name__, PromptOptions(prompt= MessageFactory.text('From what city will you be travelling?'))) + else: + return await step_context.next(booking_details.origin) + + """ + If a travel date has not been provided, prompt for one. + This will use the DATE_RESOLVER_DIALOG. + """ + async def travel_date_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + booking_details = step_context.options + + # Capture the results of the previous step + booking_details.origin = step_context.result + if (not booking_details.travel_date or self.is_ambiguous(booking_details.travelDate)): + return await step_context.begin_dialog(DateResolverDialog.__name__, date= booking_details.travel_date) + else: + return await step_context.next(booking_details.travelDate) + + """ + Confirm the information the user has provided. + """ + async def confirm_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + booking_details = step_context.options + + # Capture the results of the previous step + booking_details.travel_date= step_context.result + msg = f'Please confirm, I have you traveling to: { booking_details.destination } from: { booking_details.origin } on: { booking_details.travel_date}.' + + # Offer a YES/NO prompt. + return await step_context.prompt(ConfirmPrompt.__name__, PromptOptions(prompt= MessageFactory.text(msg))) + + """ + Complete the interaction and end the dialog. + """ + async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if step_context.result: + booking_details = step_context.options + + return await step_context.end_dialog(booking_details) + else: + return await step_context.end_dialog() + + def is_ambiguous(self, timex: str) -> bool: + timex_property = TimexProperty(timex) + return 'definite' not in timex_property.types + + + + \ No newline at end of file diff --git a/samples/Core-Bot/dialogs/cancel_and_help_dialog.py b/samples/Core-Bot/dialogs/cancel_and_help_dialog.py new file mode 100644 index 000000000..482e62b14 --- /dev/null +++ b/samples/Core-Bot/dialogs/cancel_and_help_dialog.py @@ -0,0 +1,36 @@ +from botbuilder.dialogs import ComponentDialog, DialogContext, DialogTurnResult, DialogTurnStatus +from botbuilder.schema import ActivityTypes + + +class CancelAndHelpDialog(ComponentDialog): + + def __init__(self, dialog_id: str): + super(CancelAndHelpDialog, self).__init__(dialog_id) + + async def on_begin_dialog(self, inner_dc: DialogContext, options: object) -> DialogTurnResult: + result = await self.interrupt(inner_dc) + if result is not None: + return result + + return await super(CancelAndHelpDialog, self).on_begin_dialog(inner_dc, options) + + async def on_continue_dialog(self, inner_dc: DialogContext) -> DialogTurnResult: + result = await self.interrupt(inner_dc) + if result is not None: + return result + + return await super(CancelAndHelpDialog, self).on_continue_dialog(inner_dc) + + async def interrupt(self, inner_dc: DialogContext) -> DialogTurnResult: + if inner_dc.context.activity.type == ActivityTypes.message: + text = inner_dc.context.activity.text.lower() + + if text == 'help' or text == '?': + await inner_dc.context.send_activity("Show Help...") + return DialogTurnResult(DialogTurnStatus.Waiting) + + if text == 'cancel' or text == 'quit': + await inner_dc.context.send_activity("Cancelling") + return await inner_dc.cancel_all_dialogs() + + return None \ No newline at end of file diff --git a/samples/Core-Bot/dialogs/date_resolver_dialog.py b/samples/Core-Bot/dialogs/date_resolver_dialog.py new file mode 100644 index 000000000..02de84ef4 --- /dev/null +++ b/samples/Core-Bot/dialogs/date_resolver_dialog.py @@ -0,0 +1,52 @@ +from botbuilder.core import MessageFactory +from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext +from botbuilder.dialogs.prompts import DateTimePrompt, PromptValidatorContext, PromptOptions, DateTimeResolution +from .cancel_and_help_dialog import CancelAndHelpDialog + +class DateResolverDialog(CancelAndHelpDialog): + + def __init__(self, dialog_id: str = None): + super(DateResolverDialog, self).__init__(dialog_id or DateResolverDialog.__name__) + + self.add_dialog(DateTimePrompt(DateTimePrompt.__name__, DateResolverDialog.datetime_prompt_validator)) + self.add_dialog(WaterfallDialog(WaterfallDialog.__name__, [ + self.initialStep, + self.finalStep + ])) + + self.initial_dialog_id(WaterfallDialog.__name__) + + async def initialStep(self,step_context: WaterfallStepContext) -> DialogTurnResult: + timex = step_context.options.date + + prompt_msg = 'On what date would you like to travel?' + reprompt_msg = "I'm sorry, for best results, please enter your travel date including the month, day and year." + + if timex is None: + # We were not given any date at all so prompt the user. + return await step_context.prompt(DateTimePrompt.__name__ , + PromptOptions( + prompt= MessageFactory.text(prompt_msg), + retry_prompt= MessageFactory.text(reprompt_msg) + )) + else: + # We have a Date we just need to check it is unambiguous. + if 'definite' in TimexProperty(timex).types: + # This is essentially a "reprompt" of the data we were given up front. + return await step_context.prompt(DateTimePrompt.__name__, PromptOptions(prompt= reprompt_msg)) + else: + return await step_context.next(DateTimeResolution(timex= timex)) + + async def finalStep(self, step_context: WaterfallStepContext): + timex = step_context.result[0].timex + return await step_context.end_dialog(timex) + + @staticmethod + def datetime_prompt_validator(self, prompt_context: PromptValidatorContext) -> bool: + if prompt_context.recognized.succeeded: + timex = prompt_context.recognized.value[0].timex.split('T')[0] + + #TODO: Needs TimexProperty + return 'definite' in TimexProperty(timex).types + + return False diff --git a/samples/Core-Bot/dialogs/main_dialog.py b/samples/Core-Bot/dialogs/main_dialog.py new file mode 100644 index 000000000..623b3e8da --- /dev/null +++ b/samples/Core-Bot/dialogs/main_dialog.py @@ -0,0 +1,57 @@ +from datetime import datetime +from botbuilder.dialogs import ComponentDialog, DialogSet, DialogTurnStatus, WaterfallDialog, WaterfallStepContext, DialogTurnResult +from botbuilder.dialogs.prompts import TextPrompt, ConfirmPrompt, PromptOptions +from botbuilder.core import MessageFactory + +from .booking_dialog import BookingDialog +from booking_details import BookingDetails + +class MainDialog(ComponentDialog): + + def __init__(self, configuration: dict, dialog_id: str = None): + super(MainDialog, self).__init__(dialog_id or MainDialog.__name__) + + self._configuration = configuration + + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog(BookingDialog()) + self.add_dialog(WaterfallDialog(WaterfallDialog.__name__, [ + + ])) + + async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if (not self._configuration.get("LuisAppId", "") or not self._configuration.get("LuisAPIKey", "") or not self._configuration.get("LuisAPIHostName", "")): + await step_context.Context.send_activity( + MessageFactory.text("NOTE: LUIS is not configured. To enable all capabilities, add 'LuisAppId', 'LuisAPIKey' and 'LuisAPIHostName' to the appsettings.json file.")) + + return await step_context.next() + else: + return await step_context.prompt(TextPrompt.__name__, PromptOptions(prompt = MessageFactory.text("What can I help you with today?"))) + + + async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # Call LUIS and gather any potential booking details. (Note the TurnContext has the response to the prompt.) + booking_details = await LuisHelper.ExecuteLuisQuery(self._configuration, step_context.Context) if step_context.result is not None else BookingDetails() + + # In this sample we only have a single Intent we are concerned with. However, typically a scenario + # will have multiple different Intents each corresponding to starting a different child Dialog. + + # Run the BookingDialog giving it whatever details we have from the LUIS call, it will fill out the remainder. + return await step_context.begin_dialog(BookingDialog.__name__, booking_details) + + async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + # If the child dialog ("BookingDialog") was cancelled or the user failed to confirm, the Result here will be null. + if (step_context.result is not None): + result = step_context.result + + # Now we have all the booking details call the booking service. + + # If the call to the booking service was successful tell the user. + + timeProperty = TimexProperty(result.TravelDate) + travelDateMsg = timeProperty.to_natural_language(datetime.now()) + msg = f'I have you booked to {result.Destination} from {result.Origin} on {travelDateMsg}' + await step_context.context.send_activity(MessageFactory.text(msg)) + else: + await step_context.context.send_activity(MessageFactory.text("Thank you.")) + return await step_context.end_dialog() diff --git a/samples/Core-Bot/helpers/__init__.py b/samples/Core-Bot/helpers/__init__.py new file mode 100644 index 000000000..9248b1c69 --- /dev/null +++ b/samples/Core-Bot/helpers/__init__.py @@ -0,0 +1,4 @@ +from . import activity_helper + +__all__ = [ + 'activity_helper'] \ No newline at end of file diff --git a/samples/Core-Bot/helpers/activity_helper.py b/samples/Core-Bot/helpers/activity_helper.py new file mode 100644 index 000000000..eff7a988d --- /dev/null +++ b/samples/Core-Bot/helpers/activity_helper.py @@ -0,0 +1,19 @@ +from datetime import datetime +from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, ConversationAccount + +def create_activity_reply(activity: Activity, text: str = None, locale: str = None): + + return Activity( + type = ActivityTypes.message, + timestamp = datetime.utcnow(), + from_property = ChannelAccount(id= getattr(activity.recipient, 'id', None), name= getattr(activity.recipient, 'name', None)), + recipient = ChannelAccount(id= activity.from_property.id, name= activity.from_property.name), + reply_to_id = activity.id, + service_url = activity.service_url, + channel_id = activity.channel_id, + conversation = ConversationAccount(is_group= activity.conversation.is_group, id= activity.conversation.id, name= activity.conversation.name), + text = text or '', + locale = locale or '', + attachments = [], + entities = [] + ) \ No newline at end of file From 0c00c3f9176ce9a8024df9c522cc5cc94748f257 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 25 Apr 2019 11:38:27 -0700 Subject: [PATCH 42/73] dialogs and helpers done, main.py and config pending --- samples/Core-Bot/bots/dialog_bot.py | 3 +- samples/Core-Bot/dialogs/main_dialog.py | 11 +++---- samples/Core-Bot/helpers/__init__.py | 6 ++-- samples/Core-Bot/helpers/dialog_helper.py | 14 +++++++++ samples/Core-Bot/helpers/luis_helper.py | 35 +++++++++++++++++++++++ 5 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 samples/Core-Bot/helpers/dialog_helper.py create mode 100644 samples/Core-Bot/helpers/luis_helper.py diff --git a/samples/Core-Bot/bots/dialog_bot.py b/samples/Core-Bot/bots/dialog_bot.py index ae55579d8..e067552b9 100644 --- a/samples/Core-Bot/bots/dialog_bot.py +++ b/samples/Core-Bot/bots/dialog_bot.py @@ -2,6 +2,7 @@ from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext from botbuilder.dialogs import Dialog +from helpers.dialog_helper import DialogHelper class DialogBot(ActivityHandler): @@ -26,4 +27,4 @@ async def on_turn(self, turn_context: TurnContext): await self.user_state.save_changes(turn_context, False) async def on_message_activity(self, turn_context: TurnContext): - await self.dialog.run(turn_context, self.conversation_state.create_property("DialogState")) \ No newline at end of file + await DialogHelper.run_dialog(self.dialog, turn_context, self.conversation_state.create_property("DialogState")) \ No newline at end of file diff --git a/samples/Core-Bot/dialogs/main_dialog.py b/samples/Core-Bot/dialogs/main_dialog.py index 623b3e8da..13acea2d8 100644 --- a/samples/Core-Bot/dialogs/main_dialog.py +++ b/samples/Core-Bot/dialogs/main_dialog.py @@ -5,6 +5,7 @@ from .booking_dialog import BookingDialog from booking_details import BookingDetails +from helpers.luis_helper import LuisHelper class MainDialog(ComponentDialog): @@ -21,7 +22,7 @@ def __init__(self, configuration: dict, dialog_id: str = None): async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: if (not self._configuration.get("LuisAppId", "") or not self._configuration.get("LuisAPIKey", "") or not self._configuration.get("LuisAPIHostName", "")): - await step_context.Context.send_activity( + await step_context.context.send_activity( MessageFactory.text("NOTE: LUIS is not configured. To enable all capabilities, add 'LuisAppId', 'LuisAPIKey' and 'LuisAPIHostName' to the appsettings.json file.")) return await step_context.next() @@ -31,7 +32,7 @@ async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResu async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: # Call LUIS and gather any potential booking details. (Note the TurnContext has the response to the prompt.) - booking_details = await LuisHelper.ExecuteLuisQuery(self._configuration, step_context.Context) if step_context.result is not None else BookingDetails() + booking_details = await LuisHelper.excecute_luis_query(self._configuration, step_context.context) if step_context.result is not None else BookingDetails() # In this sample we only have a single Intent we are concerned with. However, typically a scenario # will have multiple different Intents each corresponding to starting a different child Dialog. @@ -48,9 +49,9 @@ async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResu # If the call to the booking service was successful tell the user. - timeProperty = TimexProperty(result.TravelDate) - travelDateMsg = timeProperty.to_natural_language(datetime.now()) - msg = f'I have you booked to {result.Destination} from {result.Origin} on {travelDateMsg}' + time_property = TimexProperty(result.TravelDate) + travel_date_msg = time_property.to_natural_language(datetime.now()) + msg = f'I have you booked to {result.destination} from {result.origin} on {travel_date_msg}' await step_context.context.send_activity(MessageFactory.text(msg)) else: await step_context.context.send_activity(MessageFactory.text("Thank you.")) diff --git a/samples/Core-Bot/helpers/__init__.py b/samples/Core-Bot/helpers/__init__.py index 9248b1c69..3b145db5f 100644 --- a/samples/Core-Bot/helpers/__init__.py +++ b/samples/Core-Bot/helpers/__init__.py @@ -1,4 +1,6 @@ -from . import activity_helper +from . import activity_helper, luis_helper, dialog_helper __all__ = [ - 'activity_helper'] \ No newline at end of file + 'activity_helper', + 'dialog_helper' + 'luis_helper'] \ No newline at end of file diff --git a/samples/Core-Bot/helpers/dialog_helper.py b/samples/Core-Bot/helpers/dialog_helper.py new file mode 100644 index 000000000..5b6a881a4 --- /dev/null +++ b/samples/Core-Bot/helpers/dialog_helper.py @@ -0,0 +1,14 @@ +from botbuilder.core import StatePropertyAccessor, TurnContext +from botbuilder.dialogs import Dialog, DialogSet, DialogTurnStatus + +class DialogHelper: + + @staticmethod + async def run_dialog(dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor): + dialog_set = DialogSet(accessor) + dialog_set.add(dialog) + + dialog_context = await dialog_set.create_context(turn_context) + results = await dialog_context.continue_dialog() + if results.status == DialogTurnStatus.Empty: + await dialog_context.begin_dialog(dialog.id) \ No newline at end of file diff --git a/samples/Core-Bot/helpers/luis_helper.py b/samples/Core-Bot/helpers/luis_helper.py new file mode 100644 index 000000000..3e3d4a05c --- /dev/null +++ b/samples/Core-Bot/helpers/luis_helper.py @@ -0,0 +1,35 @@ +from botbuilder.ai.luis import LuisRecognizer, LuisApplication +from botbuilder.core import TurnContext + +from booking_details import BookingDetails + +class LuisHelper: + + @staticmethod + async def excecute_luis_query(configuration: dict, turn_context: TurnContext) -> BookingDetails: + booking_details = BookingDetails() + + try: + luis_application = LuisApplication( + configuration['LuisApplication'], + configuration['LuisAPIKey'], + 'https://'+configuration['LuisAPIHostName'] + ) + + recognizer = LuisRecognizer(luis_application) + recognizer_result = await recognizer.recognize(turn_context) + + intent = sorted(recognizer_result.intents, key=recognizer_result.intents.get, reverse=True)[:1] if recognizer_result.intents else None + + if intent == 'Book_flight': + # We need to get the result from the LUIS JSON which at every level returns an array. + booking_details.destination = recognizer_result.entities.get("To", {}).get("Airport", [])[:1][:1] + booking_details.origin = recognizer_result.entities.get("From", {}).get("Airport", [])[:1][:1] + + # This value will be a TIMEX. And we are only interested in a Date so grab the first result and drop the Time part. + # TIMEX is a format that represents DateTime expressions that include some ambiguity. e.g. missing a Year. + booking_details.travel_date = recognizer_result.entities.get("datetime", {}).get("timex", [])[:1].split('T')[0] + except Exception as e: + print(e) + + return booking_details \ No newline at end of file From 588a72c8680d79b3ff74942074767f4503565974 Mon Sep 17 00:00:00 2001 From: congysu Date: Thu, 25 Apr 2019 14:22:34 -0700 Subject: [PATCH 43/73] add luis recognizer etc. * add luis recognizer * update luis util * update recognizer result * add setup, about etc. --- .../botbuilder-ai/botbuilder/ai/about.py | 12 + .../botbuilder/ai/luis/__init__.py | 10 +- .../botbuilder/ai/luis/luis_recognizer.py | 346 ++++++++++++++++++ .../botbuilder/ai/luis/luis_util.py | 140 ++++--- .../botbuilder/ai/luis/recognizer_result.py | 2 +- libraries/botbuilder-ai/requirements.txt | 6 +- libraries/botbuilder-ai/setup.py | 42 +++ 7 files changed, 481 insertions(+), 77 deletions(-) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/about.py create mode 100644 libraries/botbuilder-ai/setup.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/about.py b/libraries/botbuilder-ai/botbuilder/ai/about.py new file mode 100644 index 000000000..e1aa7a5fc --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/about.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +__title__ = 'botbuilder-ai' +__version__ = os.environ["packageVersion"] if "packageVersion" in os.environ else "4.0.0.a6" +__uri__ = 'https://www.github.com/Microsoft/botbuilder-python' +__author__ = 'Microsoft' +__description__ = 'Microsoft Bot Framework Bot Builder' +__summary__ = 'Microsoft Bot Framework Bot Builder SDK for Python.' +__license__ = 'MIT' diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py index 979b1adff..d2ab8f8ff 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py @@ -3,6 +3,14 @@ from .intent_score import IntentScore from .luis_application import LuisApplication +from .luis_prediction_options import LuisPredictionOptions +from .luis_telemetry_constants import LuisTelemetryConstants from .recognizer_result import RecognizerResult -__all__ = ["IntentScore", "LuisApplication", "RecognizerResult"] +__all__ = [ + "IntentScore", + "LuisApplication", + "LuisPredictionOptions", + "LuisTelemetryConstants", + "RecognizerResult", +] diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index e69de29bb..6e57aecc7 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -0,0 +1,346 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +from typing import Dict, List, Tuple + +from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient +from azure.cognitiveservices.language.luis.runtime.models import LuisResult +from msrest.authentication import CognitiveServicesCredentials + +from botbuilder.core import ( + BotAssert, + BotTelemetryClient, + NullTelemetryClient, + TurnContext, +) +from botbuilder.schema import ActivityTypes + +from . import ( + IntentScore, + LuisApplication, + LuisPredictionOptions, + LuisTelemetryConstants, + RecognizerResult, +) +from .luis_util import LuisUtil + + +class LuisRecognizer(object): + """ + A LUIS based implementation of . + """ + + # The value type for a LUIS trace activity. + luis_trace_type: str = "https://www.luis.ai/schemas/trace" + + # The context label for a LUIS trace activity. + luis_trace_label: str = "Luis Trace" + + def __init__( + self, + application: LuisApplication, + prediction_options: LuisPredictionOptions = None, + include_api_results: bool = False, + ): + """Initializes a new instance of the class. + + :param application: The LUIS application to use to recognize text. + :type application: LuisApplication + :param prediction_options: The LUIS prediction options to use, defaults to None + :param prediction_options: LuisPredictionOptions, optional + :param include_api_results: True to include raw LUIS API response, defaults to False + :param include_api_results: bool, optional + :raises TypeError: + """ + + if application is None: + raise TypeError("LuisRecognizer.__init__(): application cannot be None.") + self._application = application + + self._options = prediction_options or LuisPredictionOptions() + + self._include_api_results = include_api_results + + self._telemetry_client = self._options.TelemetryClient + self._log_personal_information = self._options.LogPersonalInformation + + credentials = CognitiveServicesCredentials(application.EndpointKey) + self._runtime = LUISRuntimeClient(application.endpoint, credentials) + + @property + def log_personal_information(self) -> bool: + """Gets a value indicating whether to log personal information that came from the user to telemetry. + + :return: If True, personal information is logged to Telemetry; otherwise the properties will be filtered. + :rtype: bool + """ + + return self._log_personal_information + + @log_personal_information.setter + def log_personal_information(self, value: bool) -> None: + """Sets a value indicating whether to log personal information that came from the user to telemetry. + + :param value: If True, personal information is logged to Telemetry; otherwise the properties will be filtered. + :type value: bool + :return: + :rtype: None + """ + + self._log_personal_information = value + + @property + def telemetry_client(self) -> BotTelemetryClient: + """Gets the currently configured that logs the LuisResult event. + + :return: The being used to log events. + :rtype: BotTelemetryClient + """ + + return self._telemetry_client + + @telemetry_client.setter + def telemetry_client(self, value: BotTelemetryClient): + """Gets the currently configured that logs the LuisResult event. + + :param value: The being used to log events. + :type value: BotTelemetryClient + """ + + self._telemetry_client = value + + def top_intent( + self, + results: RecognizerResult, + default_intent: str = "None", + min_score: float = 0.0, + ) -> str: + """Returns the name of the top scoring intent from a set of LUIS results. + + :param results: Result set to be searched. + :type results: RecognizerResult + :param default_intent: Intent name to return should a top intent be found, defaults to "None" + :param default_intent: str, optional + :param min_score: Minimum score needed for an intent to be considered as a top intent. If all intents in the set are below this threshold then the `defaultIntent` will be returned, defaults to 0.0 + :param min_score: float, optional + :raises TypeError: + :return: The top scoring intent name. + :rtype: str + """ + + if results is None: + raise TypeError("LuisRecognizer.top_intent(): results cannot be None.") + + top_intent: str = None + top_score: float = -1.0 + if results.intents: + for intent, intent_score in results.intents.items(): + score = float(intent_score) + if score > top_score and score >= min_score: + top_intent = intent + top_score = score + + return top_intent or default_intent + + async def recognize( + self, + turn_context: TurnContext, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, float] = None, + ) -> RecognizerResult: + """Return results of the analysis (Suggested actions and intents). + + :param turn_context: Context object containing information for a single turn of conversation with a user. + :type turn_context: TurnContext + :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults to None + :param telemetry_properties: Dict[str, str], optional + :param telemetry_metrics: Additional metrics to be logged to telemetry with the LuisResult event, defaults to None + :param telemetry_metrics: Dict[str, float], optional + :return: The LUIS results of the analysis of the current message text in the current turn's context activity. + :rtype: RecognizerResult + """ + + return await self._recognize_internal( + turn_context, telemetry_properties, telemetry_metrics + ) + + async def on_recognizer_result( + self, + recognizer_result: RecognizerResult, + turn_context: TurnContext, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, float] = None, + ): + """Invoked prior to a LuisResult being logged. + + :param recognizer_result: The Luis Results for the call. + :type recognizer_result: RecognizerResult + :param turn_context: Context object containing information for a single turn of conversation with a user. + :type turn_context: TurnContext + :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults to None + :param telemetry_properties: Dict[str, str], optional + :param telemetry_metrics: Additional metrics to be logged to telemetry with the LuisResult event, defaults to None + :param telemetry_metrics: Dict[str, float], optional + """ + + properties = await self.fill_luis_event_properties( + recognizer_result, turn_context, telemetry_properties + ) + + # Track the event + self.telemetry_client.track_event( + LuisTelemetryConstants.luis_result, properties, telemetry_metrics + ) + + @staticmethod + def _get_top_k_intent_score( + intent_names: List[str], intents: Dict[str, IntentScore], index: int + ) -> Tuple[str, str]: + intent_name = "" + intent_score = "0.00" + if intent_names: + intent_name = intent_names[0] + if intents[intent_name] is not None: + intent_score = "{:.2f}".format(intents[intent_name]) + + return intent_name, intent_score + + def fill_luis_event_properties( + self, + recognizer_result: RecognizerResult, + turn_context: TurnContext, + telemetry_properties: Dict[str, str] = None, + ) -> Dict[str, str]: + """Fills the event properties for LuisResult event for telemetry. + These properties are logged when the recognizer is called. + + :param recognizer_result: Last activity sent from user. + :type recognizer_result: RecognizerResult + :param turn_context: Context object containing information for a single turn of conversation with a user. + :type turn_context: TurnContext + :param telemetry_properties: Additional properties to be logged to telemetry with the LuisResult event, defaults to None + :param telemetry_properties: Dict[str, str], optional + :return: A dictionary that is sent as "Properties" to IBotTelemetryClient.TrackEvent method for the BotMessageSend event. + :rtype: Dict[str, str] + """ + + intents = recognizer_result.intents + top_two_intents = ( + sorted(intents.keys(), key=lambda k: intents[k].score, reverse=True)[:2] + if intents + else [] + ) + + intent_name, intent_score = LuisRecognizer._get_top_k_intent_score( + top_two_intents, intents, index=0 + ) + intent2_name, intent2_score = LuisRecognizer._get_top_k_intent_score( + top_two_intents, intents, index=1 + ) + + # Add the intent score and conversation id properties + properties: Dict[str, str] = { + LuisTelemetryConstants.application_id_property: self._application.ApplicationId, + LuisTelemetryConstants.intent_property: intent_name, + LuisTelemetryConstants.intent_score_property: intent_score, + LuisTelemetryConstants.intent2_property: intent2_name, + LuisTelemetryConstants.intent_score2_property: intent2_score, + LuisTelemetryConstants.from_id_property: turn_context.Activity.From.Id, + } + + sentiment = recognizer_result.properties.get("sentiment") + if sentiment is not None and isinstance(sentiment, Dict): + label = sentiment.get("label") + if label is not None: + properties[LuisTelemetryConstants.sentiment_label_property] = str(label) + + score = sentiment.get("score") + if score is not None: + properties[LuisTelemetryConstants.sentiment_score_property] = str(score) + + entities = None + if recognizer_result.entities is not None: + entities = json.dumps(recognizer_result.entities) + properties[LuisTelemetryConstants.entities_property] = entities + + # Use the LogPersonalInformation flag to toggle logging PII data, text is a common example + if self.log_personal_information and turn_context.activity.text: + properties[ + LuisTelemetryConstants.question_property + ] = turn_context.activity.text + + # Additional Properties can override "stock" properties. + if telemetry_properties is not None: + for key in telemetry_properties: + properties[key] = telemetry_properties[key] + + return properties + + async def _recognize_internal( + self, + turn_context: TurnContext, + telemetry_properties: Dict[str, str], + telemetry_metrics: Dict[str, float], + ) -> RecognizerResult: + + BotAssert.context_not_null(turn_context) + + if turn_context.activity.type != ActivityTypes.message: + return None + + utterance: str = turn_context.activity.text if turn_context.activity is not None else None + recognizer_result: RecognizerResult = None + luis_result: LuisResult = None + + if not utterance or utterance.isspace(): + recognizer_result = RecognizerResult( + text=utterance, intents={"": IntentScore(score=1.0)}, entities={} + ) + else: + luis_result = await self._runtime.prediction.resolve( + self._application.application_id, + utterance, + timezoneOffset=self._options.timezone_offset, + verbose=self._options.include_all_intents, + staging=self._options.staging, + spellCheck=self._options.spell_check, + bingSpellCheckSubscriptionKey=self._options.bing_spell_check_subscription_key, + log=self._options.log if self._options.log is not None else True, + ) + + recognizer_result = RecognizerResult( + text=utterance, + altered_text=luis_result.altered_query, + intents=LuisUtil.get_intents(luis_result), + entities=LuisUtil.extract_entities_and_metadata( + luis_result.entities, + luis_result.composite_entities, + self._options.include_instance_data + if self._options.include_instance_data is not None + else True, + ), + ) + LuisUtil.add_properties(luis_result, recognizer_result) + if self._include_api_results: + recognizer_result.properties["luisResult"] = luis_result + + # Log telemetry + await self.on_recognizer_result( + recognizer_result, turn_context, telemetry_properties, telemetry_metrics + ) + + trace_info = { + "recognizerResult": recognizer_result, + "luisModel": {"ModelID": self._application.application_id}, + "luisOptions": self._options, + "luisResult": luis_result, + } + + await turn_context.trace_activity_async( + "LuisRecognizer", + trace_info, + LuisRecognizer.luis_trace_type, + LuisRecognizer.luis_trace_label, + ) + return recognizer_result diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index 9eec635de..fc4865d93 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -17,67 +17,67 @@ class LuisUtil: Utility functions used to extract and transform data from Luis SDK """ - _metadataKey: str = "$instance" + _metadata_key: str = "$instance" @staticmethod def normalized_intent(intent: str) -> str: return intent.replace(".", "_").replace(" ", "_") @staticmethod - def get_intents(luisResult: LuisResult) -> Dict[str, IntentScore]: - if luisResult.intents: + def get_intents(luis_result: LuisResult) -> Dict[str, IntentScore]: + if luis_result.intents: return { LuisUtil.normalized_intent(i.intent): IntentScore(i.score or 0) - for i in luisResult.intents + for i in luis_result.intents } else: return { LuisUtil.normalized_intent( - luisResult.top_scoring_intent.intent - ): IntentScore(luisResult.top_scoring_intent.score or 0) + luis_result.top_scoring_intent.intent + ): IntentScore(luis_result.top_scoring_intent.score or 0) } @staticmethod def extract_entities_and_metadata( entities: List[EntityModel], - compositeEntities: List[CompositeEntityModel], + composite_entities: List[CompositeEntityModel], verbose: bool, ) -> Dict: - entitiesAndMetadata = {} + entities_and_metadata = {} if verbose: - entitiesAndMetadata[LuisUtil._metadataKey] = {} + entities_and_metadata[LuisUtil._metadata_key] = {} - compositeEntityTypes = set() + composite_entity_types = set() # We start by populating composite entities so that entities covered by them are removed from the entities list - if compositeEntities: - compositeEntityTypes = set(ce.parent_type for ce in compositeEntities) + if composite_entities: + composite_entity_types = set(ce.parent_type for ce in composite_entities) current = entities - for compositeEntity in compositeEntities: + for compositeEntity in composite_entities: current = LuisUtil.populate_composite_entity_model( - compositeEntity, current, entitiesAndMetadata, verbose + compositeEntity, current, entities_and_metadata, verbose ) entities = current for entity in entities: # we'll address composite entities separately - if entity.type in compositeEntityTypes: + if entity.type in composite_entity_types: continue LuisUtil.add_property( - entitiesAndMetadata, + entities_and_metadata, LuisUtil.extract_normalized_entity_name(entity), LuisUtil.extract_entity_value(entity), ) if verbose: LuisUtil.add_property( - entitiesAndMetadata[LuisUtil._metadataKey], + entities_and_metadata[LuisUtil._metadata_key], LuisUtil.extract_normalized_entity_name(entity), LuisUtil.extract_entity_metadata(entity), ) - return entitiesAndMetadata + return entities_and_metadata @staticmethod def number(value: object) -> Union(int, float): @@ -95,23 +95,23 @@ def number(value: object) -> Union(int, float): @staticmethod def extract_entity_value(entity: EntityModel) -> object: if ( - entity.AdditionalProperties is None - or "resolution" not in entity.AdditionalProperties + entity.additional_properties is None + or "resolution" not in entity.additional_properties ): return entity.entity - resolution = entity.AdditionalProperty["resolution"] - if entity.Type.startswith("builtin.datetime."): + resolution = entity.additional_properties["resolution"] + if entity.type.startswith("builtin.datetime."): return resolution - elif entity.Type.startswith("builtin.datetimeV2."): + elif entity.type.startswith("builtin.datetimeV2."): if not resolution.values: return resolution - resolutionValues = resolution.values + resolution_values = resolution.values val_type = resolution.values[0].type - timexes = [val.timex for val in resolutionValues] - distinctTimexes = list(set(timexes)) - return {"type": val_type, "timex": distinctTimexes} + timexes = [val.timex for val in resolution_values] + distinct_timexes = list(set(timexes)) + return {"type": val_type, "timex": distinct_timexes} else: if entity.type in {"builtin.number", "builtin.ordinal"}: return LuisUtil.number(resolution.value) @@ -135,7 +135,6 @@ def extract_entity_value(entity: EntityModel) -> object: obj["units"] = units return obj - else: return resolution.value or resolution.values @@ -148,11 +147,11 @@ def extract_entity_metadata(entity: EntityModel) -> Dict: type=entity.type, ) - if entity.AdditionalProperties is not None: - if "score" in entity.AdditionalProperties: - obj["score"] = float(entity.AdditionalProperties["score"]) + if entity.additional_properties is not None: + if "score" in entity.additional_properties: + obj["score"] = float(entity.additional_properties["score"]) - resolution = entity.AdditionalProperties.get("resolution") + resolution = entity.additional_properties.get("resolution") if resolution is not None and resolution.subtype is not None: obj["subtype"] = resolution.subtype @@ -172,9 +171,9 @@ def extract_normalized_entity_name(entity: EntityModel) -> str: type = type[8:] role = ( - entity.AdditionalProperties["role"] - if entity.AdditionalProperties is not None - and "role" in entity.AdditionalProperties + entity.additional_properties["role"] + if entity.additional_properties is not None + and "role" in entity.additional_properties else "" ) if role and not role.isspace(): @@ -184,87 +183,87 @@ def extract_normalized_entity_name(entity: EntityModel) -> str: @staticmethod def populate_composite_entity_model( - compositeEntity: CompositeEntityModel, + composite_entity: CompositeEntityModel, entities: List[EntityModel], - entitiesAndMetadata: Dict, + entities_and_metadata: Dict, verbose: bool, ) -> List[EntityModel]: - childrenEntites = {} - childrenEntitiesMetadata = {} + children_entities = {} + children_entities_metadata = {} if verbose: - childrenEntites[LuisUtil._metadataKey] = {} + children_entities[LuisUtil._metadata_key] = {} # This is now implemented as O(n^2) search and can be reduced to O(2n) using a map as an optimization if n grows - compositeEntityMetadata = next( + composite_entity_metadata = next( ( e for e in entities - if e.type == compositeEntity.parent_type - and e.entity == compositeEntity.value + if e.type == composite_entity.parent_type + and e.entity == composite_entity.value ), None, ) # This is an error case and should not happen in theory - if compositeEntityMetadata is None: + if composite_entity_metadata is None: return entities if verbose: - childrenEntitiesMetadata = LuisUtil.extract_entity_metadata( - compositeEntityMetadata + children_entities_metadata = LuisUtil.extract_entity_metadata( + composite_entity_metadata ) - childrenEntites[LuisUtil._metadataKey] = {} + children_entities[LuisUtil._metadata_key] = {} - coveredSet: Set[EntityModel] = set() - for child in compositeEntity.Children: + covered_set: Set[EntityModel] = set() + for child in composite_entity.children: for entity in entities: # We already covered this entity - if entity in coveredSet: + if entity in covered_set: continue # This entity doesn't belong to this composite entity if child.Type != entity.Type or not LuisUtil.composite_contains_entity( - compositeEntityMetadata, entity + composite_entity_metadata, entity ): continue # Add to the set to ensure that we don't consider the same child entity more than once per composite - coveredSet.add(entity) + covered_set.add(entity) LuisUtil.add_property( - childrenEntites, + children_entities, LuisUtil.extract_normalized_entity_name(entity), LuisUtil.extract_entity_value(entity), ) if verbose: LuisUtil.add_property( - childrenEntites[LuisUtil._metadataKey], + children_entities[LuisUtil._metadata_key], LuisUtil.extract_normalized_entity_name(entity), LuisUtil.extract_entity_metadata(entity), ) LuisUtil.add_property( - entitiesAndMetadata, - LuisUtil.extract_normalized_entity_name(compositeEntityMetadata), - childrenEntites, + entities_and_metadata, + LuisUtil.extract_normalized_entity_name(composite_entity_metadata), + children_entities, ) if verbose: LuisUtil.add_property( - entitiesAndMetadata[LuisUtil._metadataKey], - LuisUtil.extract_normalized_entity_name(compositeEntityMetadata), - childrenEntitiesMetadata, + entities_and_metadata[LuisUtil._metadata_key], + LuisUtil.extract_normalized_entity_name(composite_entity_metadata), + children_entities_metadata, ) # filter entities that were covered by this composite entity - return [entity for entity in entities if entity not in coveredSet] + return [entity for entity in entities if entity not in covered_set] @staticmethod def composite_contains_entity( - compositeEntityMetadata: EntityModel, entity: EntityModel + composite_entity_metadata: EntityModel, entity: EntityModel ) -> bool: return ( - entity.StartIndex >= compositeEntityMetadata.StartIndex - and entity.EndIndex <= compositeEntityMetadata.EndIndex + entity.start_index >= composite_entity_metadata.start_index + and entity.end_index <= composite_entity_metadata.end_index ) @staticmethod @@ -278,11 +277,8 @@ def add_property(obj: Dict[str, object], key: str, value: object) -> None: @staticmethod def add_properties(luis: LuisResult, result: RecognizerResult) -> None: - if luis.SentimentAnalysis is not None: - result.Properties.Add( - "sentiment", - { - "label": luis.SentimentAnalysis.Label, - "score": luis.SentimentAnalysis.Score, - }, - ) + if luis.sentiment_analysis is not None: + result.properties["sentiment"] = { + "label": luis.sentiment_analysis.label, + "score": luis.sentiment_analysis.score, + } diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py index a6c732ff8..003624f6f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py @@ -11,7 +11,7 @@ class RecognizerResult: Contains recognition results generated by a recognizer. """ - def __init__(self): + def __init__(self, text:str=None, altered_text:str=None, intents: Dict[str, IntentScore]=None, entities :Dict=None): self._text: str = None self._altered_text: str = None self._intents: Dict[str, IntentScore] = None diff --git a/libraries/botbuilder-ai/requirements.txt b/libraries/botbuilder-ai/requirements.txt index 85ba5d03b..c713fd126 100644 --- a/libraries/botbuilder-ai/requirements.txt +++ b/libraries/botbuilder-ai/requirements.txt @@ -1,8 +1,8 @@ -#msrest>=0.6.6 +msrest>=0.6.6 #botframework-connector>=4.0.0.a6 -#botbuilder-schema>=4.0.0.a6 +botbuilder-schema>=4.0.0.a6 botbuilder-core>=4.0.0.a6 -#requests>=2.18.1 +requests>=2.18.1 #PyJWT==1.5.3 #cryptography==2.1.4 #aiounittest>=1.1.0 diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py new file mode 100644 index 000000000..3f231cfdb --- /dev/null +++ b/libraries/botbuilder-ai/setup.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "aiounittest>=1.1.0", + "azure-cognitiveservices-language-luis==0.1.0", + "botbuilder-schema>=4.0.0.a6", + #"botframework-connector>=4.0.0.a6", + "botbuilder-core>=4.0.0.a6", +] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "botbuilder", "ai", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords=["BotBuilderDialogs", "bots", "ai", "botframework", "botbuilder"], + long_description=package_info["__summary__"], + license=package_info["__license__"], + packages=["botbuilder.ai"], + install_requires=REQUIRES, + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.6", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 3 - Alpha", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) From d96c3b3613a75744423bb0330762befec729dff3 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Thu, 25 Apr 2019 18:03:04 -0700 Subject: [PATCH 44/73] created QnAMaker class --- .../botbuilder/ai/qna/__init__.py | 6 + .../botbuilder/ai/qna/qnamaker.py | 260 ++++++++++++++++++ libraries/botbuilder-ai/setup.py | 42 +++ 3 files changed, 308 insertions(+) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py create mode 100644 libraries/botbuilder-ai/setup.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py new file mode 100644 index 000000000..a64b7c3b5 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .qnamaker import QnAMaker + +__all__ = ["QnAMaker"] \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py new file mode 100644 index 000000000..f6cbf9f27 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -0,0 +1,260 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.schema import Activity, ChannelAccount, ResourceResponse, ConversationAccount +from botbuilder.core import BotAdapter, BotTelemetryClient, NullTelemetryClient, TurnContext +# import http.client, urllib.parse, json, time, urllib.request +import json, requests +from copy import copy +from typing import Dict +import asyncio +from abc import ABC, abstractmethod + +QNAMAKER_TRACE_TYPE = 'https://www.qnamaker.ai/schemas/trace' +QNAMAKER_TRACE_NAME = 'QnAMaker' +QNAMAKER_TRACE_LABEL = 'QnAMaker Trace' + +# DELETE YO +class SimpleAdapter(BotAdapter): + async def send_activities(self, context, activities): + responses = [] + for (idx, activity) in enumerate(activities): + responses.append(ResourceResponse(id='5678')) + return responses + + async def update_activity(self, context, activity): + assert context is not None + assert activity is not None + + async def delete_activity(self, context, reference): + assert context is not None + assert reference is not None + assert reference.activity_id == '1234' + +ACTIVITY = Activity(id='1234', + type='message', + text='up', + from_property=ChannelAccount(id='user', name='User Name'), + recipient=ChannelAccount(id='bot', name='Bot Name'), + conversation=ConversationAccount(id='convo', name='Convo Name'), + channel_id='UnitTest', + service_url='https://example.org' + ) + +class Metadata: + def __init__(self, name, value): + self.name = name + self.value = value + +class QueryResult: + def __init__(self, questions: str, answer: str, score: float, metadata: [Metadata], source: str, id: int): + self.questions = questions, + self.answer = answer, + self.score = score, + self.metadata = Metadata, + self.source = source + self.id = id + +class QnAMakerEndpoint: + def __init__(self, knowledge_base_id: str, endpoint_key: str, host: str): + self.knowledge_base_id = knowledge_base_id + self.endpoint_key = endpoint_key + self.host = host + +# figure out if 300 milliseconds is ok for python requests library...or 100000 +class QnAMakerOptions: + def __init__(self, score_threshold: float = 0.0, timeout: int = 0, top: int = 0, strict_filters: [Metadata] = []): + self.score_threshold = score_threshold + self.timeout = timeout + self.top = top + self.strict_filters = strict_filters + +class QnAMakerTelemetryClient(ABC): + def __init__(self, log_personal_information: bool, telemetry_client: BotTelemetryClient): + self.log_personal_information = log_personal_information, + self.telemetry_client = telemetry_client + + @abstractmethod + def get_answers(self, context: TurnContext, options: QnAMakerOptions = None, telemetry_properties: Dict[str,str] = None, telemetry_metrics: Dict[str, int] = None): + raise NotImplementedError('QnAMakerTelemetryClient.get_answers(): is not implemented.') + +class QnAMakerTraceInfo: + def __init__(self, message, query_results, knowledge_base_id, score_threshold, top, strict_filters): + self.message = message, + self.query_results = query_results, + self.knowledge_base_id = knowledge_base_id, + self.score_threshold = score_threshold, + self.top = top, + self.strict_filters = strict_filters + +class QnAMaker(): + def __init__(self, endpoint: QnAMakerEndpoint, options: QnAMakerOptions = QnAMakerOptions()): + self._endpoint = endpoint + self._is_legacy_protocol: bool = self._endpoint.host.endswith('v3.0') + self._options: QnAMakerOptions = options + self.validate_options(self._options) + + + async def get_answers(self, context: TurnContext, options: QnAMakerOptions = None): + # don't forget to add timeout + # maybe omit metadata boost? + hydrated_options = self.hydrate_options(options) + self.validate_options(hydrated_options) + + result = self.query_qna_service(context.activity, hydrated_options) + + await self.emit_trace_info(context, result, hydrated_options) + + return result + + def validate_options(self, options: QnAMakerOptions): + if not options.score_threshold: + options.score_threshold = 0.3 + + if not options.top: + options.top = 1 + + # write range error for if scorethreshold < 0 or > 1 + + if not options.timeout: + options.timeout = 100000 # check timeout units in requests module + + # write range error for if top < 1 + + if not options.strict_filters: + options.strict_filters = [Metadata] + + def hydrate_options(self, query_options: QnAMakerOptions): + hydrated_options = copy(self._options) + + if query_options: + if (query_options.score_threshold != hydrated_options.score_threshold and query_options.score_threshold): + hydrated_options.score_threshold = query_options.score_threshold + + if (query_options.top != hydrated_options.top and query_options.top != 0): + hydrated_options.top = query_options.top + + if (len(query_options.strict_filters) > 0): + hydrated_options.strict_filters = query_options.strict_filters + + return hydrated_options + + def query_qna_service(self, message_activity: Activity, options: QnAMakerOptions): + url = f'{ self._endpoint.host }/knowledgebases/{ self._endpoint.knowledge_base_id }/generateAnswer' + + question = { + 'question': context.activity.text, + 'top': options.top, + 'scoreThreshold': options.score_threshold, + 'strictFilters': options.strict_filters + } + + serialized_content = json.dumps(question) + + headers = self.get_headers() + + response = requests.post(url, data=serialized_content, headers=headers) + + result = self.format_qna_result(response, options) + + return result + + async def emit_trace_info(self, turn_context: TurnContext, result: [QueryResult], options: QnAMakerOptions): + trace_info = QnAMakerTraceInfo( + message = turn_context.activity, + query_results = result, + knowledge_base_id = self._endpoint.knowledge_base_id, + score_threshold = options.score_threshold, + top = options.top, + strict_filters = options.strict_filters + ) + + trace_activity = Activity( + label = QNAMAKER_TRACE_LABEL, + name = QNAMAKER_TRACE_NAME, + type = 'trace', + value = trace_info, + value_type = QNAMAKER_TRACE_TYPE + ) + + await turn_context.send_activity(trace_activity) + + def format_qna_result(self, qna_result: requests.Response, options: QnAMakerOptions): + result = qna_result.json() + + answers_within_threshold = [ + { **answer,'score': answer['score']/100 } for answer in result['answers'] + if answer['score']/100 > options.score_threshold + ] + sorted_answers = sorted(answers_within_threshold, key = lambda ans: ans['score'], reverse = True) + + if self._is_legacy_protocol: + for answer in answers_within_threshold: + answer['id'] = answer.pop('qnaId', None) + + answers_as_query_results = list(map(lambda answer: QueryResult(**answer), sorted_answers)) + + return answers_as_query_results + + def get_headers(self): + headers = { 'Content-Type': 'application/json' } + + if self._is_legacy_protocol: + headers['Ocp-Apim-Subscription-Key'] = self._endpoint.endpoint_key + else: + headers['Authorization'] = f'EndpointKey {self._endpoint.endpoint_key}' + # need user-agent header + return headers + + + + + + +adapter = SimpleAdapter() +context = TurnContext(adapter, ACTIVITY) + +endpointy = QnAMakerEndpoint('a090f9f3-2f8e-41d1-a581-4f7a49269a0c', '4a439d5b-163b-47c3-b1d1-168cc0db5608', 'https://ashleyNlpBot1-qnahost.azurewebsites.net/qnamaker') +qna = QnAMaker(endpointy) +optionsies = QnAMakerOptions(top=3, strict_filters=[{'name': 'movie', 'value': 'disney'}]) + +loop = asyncio.get_event_loop() +r = loop.run_until_complete((qna.get_answers(context, optionsies))) +loop.close() + +# result = qna.get_answers(context) +# print(type(result)) +# print(r) + +print('donesies!') + +# context2 = TurnContext(adapter, ACTIVITY) +# print(context2.__dict__.update({'test': '1'})) + +# qna_ressy = { +# 'answers': [ +# { +# 'questions': ['hi', 'greetings', 'good morning', 'good evening'], +# 'answer': 'Hello!', +# 'score': 100.0, +# 'id': 1, +# 'source': 'QnAMaker.tsv', +# 'metadata': [] +# }, +# { +# 'questions': ['hi', 'greetings', 'good morning', 'good evening'], +# 'answer': 'hi!', +# 'score': 80.0, +# 'id': 1, +# 'source': 'QnAMaker.tsv', +# 'metadata': [] +# } +# ], +# 'debugInfo': None +# } + +# my_first_ans = qna_ressy['answers'][0] + +# my_query = QueryResult(**my_first_ans) + +# print(my_query) \ No newline at end of file diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py new file mode 100644 index 000000000..1dd3fccb4 --- /dev/null +++ b/libraries/botbuilder-ai/setup.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from setuptools import setup + +REQUIRES = [ + "aiounittest>=1.1.0", + "azure-cognitiveservices-language-luis==0.1.0", + "botbuilder-schema>=4.0.0.a6", + #"botframework-connector>=4.0.0.a6", + "botbuilder-core>=4.0.0.a6", +] + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "botbuilder", "ai", "about.py")) as f: + package_info = {} + info = f.read() + exec(info, package_info) + +setup( + name=package_info["__title__"], + version=package_info["__version__"], + url=package_info["__uri__"], + author=package_info["__author__"], + description=package_info["__description__"], + keywords=["BotBuilderDialogs", "bots", "ai", "botframework", "botbuilder"], + long_description=package_info["__summary__"], + license=package_info["__license__"], + packages=["botbuilder.ai"], + install_requires=REQUIRES, + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.6", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Development Status :: 3 - Alpha", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + ], +) \ No newline at end of file From ade02a84ca7dc13f7a575f2d842497b6719e10cb Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 25 Apr 2019 18:09:13 -0700 Subject: [PATCH 45/73] sample done, yaml configand testing missing --- .../botbuilder/core/bot_framework_adapter.py | 16 ++--- samples/Core-Bot/bots/__init__.py | 4 +- samples/Core-Bot/main.py | 66 ++++--------------- 3 files changed, 22 insertions(+), 64 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py index c7842e1ef..d3cda3213 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py @@ -62,7 +62,7 @@ async def create_conversation(self, reference: ConversationReference, logic): parameters = ConversationParameters(bot=reference.bot) client = self.create_connector_client(reference.service_url) - resource_response = await client.conversations.create_conversation_async(parameters) + resource_response = await client.conversations.create_conversation(parameters) request = TurnContext.apply_conversation_reference(Activity(), reference, is_incoming=True) request.conversation = ConversationAccount(id=resource_response.id) if resource_response.service_url: @@ -158,7 +158,7 @@ async def update_activity(self, context: TurnContext, activity: Activity): """ try: client = self.create_connector_client(activity.service_url) - return await client.conversations.update_activity_async( + return await client.conversations.update_activity( activity.conversation.id, activity.conversation.activity_id, activity) @@ -175,7 +175,7 @@ async def delete_activity(self, context: TurnContext, conversation_reference: Co """ try: client = self.create_connector_client(conversation_reference.service_url) - await client.conversations.delete_activity_async(conversation_reference.conversation.id, + await client.conversations.delete_activity(conversation_reference.conversation.id, conversation_reference.activity_id) except Exception as e: raise e @@ -194,7 +194,7 @@ async def send_activities(self, context: TurnContext, activities: List[Activity] await asyncio.sleep(delay_in_ms) else: client = self.create_connector_client(activity.service_url) - await client.conversations.send_to_conversation_async(activity.conversation.id, activity) + await client.conversations.send_to_conversation(activity.conversation.id, activity) except Exception as e: raise e @@ -214,7 +214,7 @@ async def delete_conversation_member(self, context: TurnContext, member_id: str) service_url = context.activity.service_url conversation_id = context.activity.conversation.id client = self.create_connector_client(service_url) - return await client.conversations.delete_conversation_member_async(conversation_id, member_id) + return await client.conversations.delete_conversation_member(conversation_id, member_id) except AttributeError as attr_e: raise attr_e except Exception as e: @@ -240,7 +240,7 @@ async def get_activity_members(self, context: TurnContext, activity_id: str): service_url = context.activity.service_url conversation_id = context.activity.conversation.id client = self.create_connector_client(service_url) - return await client.conversations.get_activity_members_async(conversation_id, activity_id) + return await client.conversations.get_activity_members(conversation_id, activity_id) except Exception as e: raise e @@ -259,7 +259,7 @@ async def get_conversation_members(self, context: TurnContext): service_url = context.activity.service_url conversation_id = context.activity.conversation.id client = self.create_connector_client(service_url) - return await client.conversations.get_conversation_members_async(conversation_id) + return await client.conversations.get_conversation_members(conversation_id) except Exception as e: raise e @@ -273,7 +273,7 @@ async def get_conversations(self, service_url: str, continuation_token: str=None :return: """ client = self.create_connector_client(service_url) - return await client.conversations.get_conversations_async(continuation_token) + return await client.conversations.get_conversations(continuation_token) def create_connector_client(self, service_url: str) -> ConnectorClient: """ diff --git a/samples/Core-Bot/bots/__init__.py b/samples/Core-Bot/bots/__init__.py index 8c599914d..8a5635f35 100644 --- a/samples/Core-Bot/bots/__init__.py +++ b/samples/Core-Bot/bots/__init__.py @@ -1,4 +1,6 @@ from .dialog_bot import DialogBot +from .dialog_and_welcome_bot import DialogAndWelcomeBot __all__ = [ - 'DialogBot'] \ No newline at end of file + 'DialogBot', + 'DialogAndWelcomeBot'] \ No newline at end of file diff --git a/samples/Core-Bot/main.py b/samples/Core-Bot/main.py index e966076cb..9fbb1f397 100644 --- a/samples/Core-Bot/main.py +++ b/samples/Core-Bot/main.py @@ -11,6 +11,10 @@ from botbuilder.core import (BotFrameworkAdapter, BotFrameworkAdapterSettings, TurnContext, ConversationState, MemoryStorage, UserState) +from dialogs import MainDialog +from bots import DialogAndWelcomeBot +from helpers.dialog_helper import DialogHelper + APP_ID = '' APP_PASSWORD = '' PORT = 9000 @@ -19,73 +23,25 @@ # Create MemoryStorage, UserState and ConversationState memory = MemoryStorage() -# Commented out user_state because it's not being used. -# user_state = UserState(memory) -conversation_state = ConversationState(memory) - -# Register both State middleware on the adapter. -# Commented out user_state because it's not being used. -# ADAPTER.use(user_state) -ADAPTER.use(conversation_state) - - -async def create_reply_activity(request_activity, text) -> Activity: - return Activity( - type=ActivityTypes.message, - channel_id=request_activity.channel_id, - conversation=request_activity.conversation, - recipient=request_activity.from_property, - from_property=request_activity.recipient, - text=text, - service_url=request_activity.service_url) - - -async def handle_message(context: TurnContext) -> web.Response: - # Access the state for the conversation between the user and the bot. - state = await conversation_state.get(context) - - if hasattr(state, 'counter'): - state.counter += 1 - else: - state.counter = 1 - response = await create_reply_activity(context.activity, f'{state.counter}: You said {context.activity.text}.') - await context.send_activity(response) - return web.Response(status=202) - - -async def handle_conversation_update(context: TurnContext) -> web.Response: - if context.activity.members_added[0].id != context.activity.recipient.id: - response = await create_reply_activity(context.activity, 'Welcome to the Echo Adapter Bot!') - await context.send_activity(response) - return web.Response(status=200) - - -async def unhandled_activity() -> web.Response: - return web.Response(status=404) - - -async def request_handler(context: TurnContext) -> web.Response: - if context.activity.type == 'message': - return await handle_message(context) - elif context.activity.type == 'conversationUpdate': - return await handle_conversation_update(context) - else: - return await unhandled_activity() +user_state = UserState(memory) +conversation_state = ConversationState(memory) +dialog = MainDialog({}) +bot = DialogAndWelcomeBot(conversation_state, user_state, dialog) -async def messages(req: web.web_request) -> web.Response: +async def messages(req: web.Request) -> web.Response: body = await req.json() activity = Activity().deserialize(body) auth_header = req.headers['Authorization'] if 'Authorization' in req.headers else '' try: - return await ADAPTER.process_activity(activity, auth_header, request_handler) + return await ADAPTER.process_activity(activity, auth_header, lambda turn_context: await bot.on_turn(turn_context)) except Exception as e: raise e app = web.Application() -app.router.add_post('/', messages) +app.router.add_post('/api/messages', messages) try: web.run_app(app, host='localhost', port=PORT) From 52619aea4eb3a5cb84a2bb5d9e19fbefac0c6dbd Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Fri, 26 Apr 2019 06:58:37 -0700 Subject: [PATCH 46/73] qna w/basic functionality in 1 file --- .../botbuilder/ai/qna/__init__.py | 6 +- .../ai/qna/qna_telemetry_constants.py | 23 ++ .../botbuilder/ai/qna/qnamaker.py | 217 +++++++++++------- 3 files changed, 161 insertions(+), 85 deletions(-) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/qna_telemetry_constants.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py index a64b7c3b5..bf03f403e 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py @@ -2,5 +2,9 @@ # Licensed under the MIT License. from .qnamaker import QnAMaker +from .qna_telemetry_constants import QnATelemetryConstants -__all__ = ["QnAMaker"] \ No newline at end of file +__all__ = [ + "QnAMaker", + "QnATelemetryConstants" +] \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qna_telemetry_constants.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qna_telemetry_constants.py new file mode 100644 index 000000000..5d37cf838 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qna_telemetry_constants.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from enum import Enum + + +class QnATelemetryConstants(str, Enum): + """ + The IBotTelemetryClient event and property names that logged by default. + """ + + qna_message_event = 'QnaMessage' + """Event name""" + knowledge_base_id_property = 'knowledgeBaseId' + answer_property = 'answer' + article_found_property = 'articleFound' + channel_id_property = 'channelId' + conversation_id_property = 'conversationId' + question_property = 'question' + matched_question_property = 'matchedQuestion' + question_id_property = 'questionId' + score_metric = 'score' + username_property = 'username' \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index f6cbf9f27..da3a07fc3 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -6,31 +6,20 @@ # import http.client, urllib.parse, json, time, urllib.request import json, requests from copy import copy -from typing import Dict +from typing import Dict, List, Tuple +from enum import Enum + import asyncio from abc import ABC, abstractmethod +# from . import( +# QnATelemetryConstants +# ) + QNAMAKER_TRACE_TYPE = 'https://www.qnamaker.ai/schemas/trace' QNAMAKER_TRACE_NAME = 'QnAMaker' QNAMAKER_TRACE_LABEL = 'QnAMaker Trace' -# DELETE YO -class SimpleAdapter(BotAdapter): - async def send_activities(self, context, activities): - responses = [] - for (idx, activity) in enumerate(activities): - responses.append(ResourceResponse(id='5678')) - return responses - - async def update_activity(self, context, activity): - assert context is not None - assert activity is not None - - async def delete_activity(self, context, reference): - assert context is not None - assert reference is not None - assert reference.activity_id == '1234' - ACTIVITY = Activity(id='1234', type='message', text='up', @@ -69,17 +58,53 @@ def __init__(self, score_threshold: float = 0.0, timeout: int = 0, top: int = 0, self.top = top self.strict_filters = strict_filters +class QnATelemetryConstants(str, Enum): + """ + The IBotTelemetryClient event and property names that logged by default. + """ + + qna_message_event = 'QnaMessage' + """Event name""" + knowledge_base_id_property = 'knowledgeBaseId' + answer_property = 'answer' + article_found_property = 'articleFound' + channel_id_property = 'channelId' + conversation_id_property = 'conversationId' + question_property = 'question' + matched_question_property = 'matchedQuestion' + question_id_property = 'questionId' + score_metric = 'score' + username_property = 'username' + class QnAMakerTelemetryClient(ABC): - def __init__(self, log_personal_information: bool, telemetry_client: BotTelemetryClient): + def __init__( + self, + log_personal_information: bool, + telemetry_client: BotTelemetryClient + ): self.log_personal_information = log_personal_information, self.telemetry_client = telemetry_client @abstractmethod - def get_answers(self, context: TurnContext, options: QnAMakerOptions = None, telemetry_properties: Dict[str,str] = None, telemetry_metrics: Dict[str, int] = None): + def get_answers( + self, + context: TurnContext, + options: QnAMakerOptions = None, + telemetry_properties: Dict[str,str] = None, + telemetry_metrics: Dict[str, float] = None + ): raise NotImplementedError('QnAMakerTelemetryClient.get_answers(): is not implemented.') class QnAMakerTraceInfo: - def __init__(self, message, query_results, knowledge_base_id, score_threshold, top, strict_filters): + def __init__( + self, + message: Activity, + query_results: [QueryResult], + knowledge_base_id, + score_threshold, + top, + strict_filters + ): self.message = message, self.query_results = query_results, self.knowledge_base_id = knowledge_base_id, @@ -87,17 +112,91 @@ def __init__(self, message, query_results, knowledge_base_id, score_threshold, t self.top = top, self.strict_filters = strict_filters -class QnAMaker(): - def __init__(self, endpoint: QnAMakerEndpoint, options: QnAMakerOptions = QnAMakerOptions()): +class QnAMaker(QnAMakerTelemetryClient): + def __init__( + self, + endpoint: QnAMakerEndpoint, + options: QnAMakerOptions = QnAMakerOptions(), + telemetry_client: BotTelemetryClient = None, + log_personal_information: bool = None + ): self._endpoint = endpoint self._is_legacy_protocol: bool = self._endpoint.host.endswith('v3.0') self._options: QnAMakerOptions = options - self.validate_options(self._options) + self._telemetry_client = telemetry_client or NullTelemetryClient() + self._log_personal_information = log_personal_information or False + self.validate_options(self._options) - async def get_answers(self, context: TurnContext, options: QnAMakerOptions = None): - # don't forget to add timeout - # maybe omit metadata boost? + @property + def log_personal_information(self) -> bool: + """Gets a value indicating whether to log personal information that came from the user to telemetry. + + :return: If True, personal information is logged to Telemetry; otherwise the properties will be filtered. + :rtype: bool + """ + + return self._log_personal_information + + @log_personal_information.setter + def log_personal_information(self, value: bool) -> None: + """Sets a value indicating whether to log personal information that came from the user to telemetry. + + :param value: If True, personal information is logged to Telemetry; otherwise the properties will be filtered. + :type value: bool + :return: + :rtype: None + """ + + self._log_personal_information = value + + @property + def telemetry_client(self) -> BotTelemetryClient: + """Gets the currently configured BotTelemetryClient that logs the event. + + :return: The BotTelemetryClient being used to log events. + :rtype: BotTelemetryClient + """ + + return self._telemetry_client + + @telemetry_client.setter + def telemetry_client(self, value: BotTelemetryClient): + """Sets the currently configured BotTelemetryClient that logs the event. + + :param value: The BotTelemetryClient being used to log events. + :type value: BotTelemetryClient + """ + + self._telemetry_client = value + + async def on_qna_result(self): + # event_data = await fill_qna_event() + pass + + async def fill_qna_event( + self, + query_results: [QueryResult], + turn_context: TurnContext, + telemetry_properties: Dict[str,str] = None, + telemetry_metrics: Dict[str,float] = None + ) -> Tuple[ Dict[str, str], Dict[str,int] ]: + + properties: Dict[str,str] = dict() + metrics: Dict[str, float] = dict() + + properties[QnATelemetryConstants.knowledge_base_id_property] = self._endpoint.knowledge_base_id + + pass + + async def get_answers( + self, + context: TurnContext, + options: QnAMakerOptions = None, + telemetry_properties: Dict[str,str] = None, + telemetry_metrics: Dict[str,int] = None + ): + # add timeout hydrated_options = self.hydrate_options(options) self.validate_options(hydrated_options) @@ -124,7 +223,7 @@ def validate_options(self, options: QnAMakerOptions): if not options.strict_filters: options.strict_filters = [Metadata] - def hydrate_options(self, query_options: QnAMakerOptions): + def hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: hydrated_options = copy(self._options) if query_options: @@ -139,11 +238,11 @@ def hydrate_options(self, query_options: QnAMakerOptions): return hydrated_options - def query_qna_service(self, message_activity: Activity, options: QnAMakerOptions): + def query_qna_service(self, message_activity: Activity, options: QnAMakerOptions) -> [QueryResult]: url = f'{ self._endpoint.host }/knowledgebases/{ self._endpoint.knowledge_base_id }/generateAnswer' question = { - 'question': context.activity.text, + 'question': message_activity.text, 'top': options.top, 'scoreThreshold': options.score_threshold, 'strictFilters': options.strict_filters @@ -179,7 +278,7 @@ async def emit_trace_info(self, turn_context: TurnContext, result: [QueryResult] await turn_context.send_activity(trace_activity) - def format_qna_result(self, qna_result: requests.Response, options: QnAMakerOptions): + def format_qna_result(self, qna_result: requests.Response, options: QnAMakerOptions) -> [QueryResult]: result = qna_result.json() answers_within_threshold = [ @@ -203,58 +302,8 @@ def get_headers(self): headers['Ocp-Apim-Subscription-Key'] = self._endpoint.endpoint_key else: headers['Authorization'] = f'EndpointKey {self._endpoint.endpoint_key}' - # need user-agent header - return headers - + # need user-agent header - - - -adapter = SimpleAdapter() -context = TurnContext(adapter, ACTIVITY) - -endpointy = QnAMakerEndpoint('a090f9f3-2f8e-41d1-a581-4f7a49269a0c', '4a439d5b-163b-47c3-b1d1-168cc0db5608', 'https://ashleyNlpBot1-qnahost.azurewebsites.net/qnamaker') -qna = QnAMaker(endpointy) -optionsies = QnAMakerOptions(top=3, strict_filters=[{'name': 'movie', 'value': 'disney'}]) - -loop = asyncio.get_event_loop() -r = loop.run_until_complete((qna.get_answers(context, optionsies))) -loop.close() - -# result = qna.get_answers(context) -# print(type(result)) -# print(r) - -print('donesies!') - -# context2 = TurnContext(adapter, ACTIVITY) -# print(context2.__dict__.update({'test': '1'})) - -# qna_ressy = { -# 'answers': [ -# { -# 'questions': ['hi', 'greetings', 'good morning', 'good evening'], -# 'answer': 'Hello!', -# 'score': 100.0, -# 'id': 1, -# 'source': 'QnAMaker.tsv', -# 'metadata': [] -# }, -# { -# 'questions': ['hi', 'greetings', 'good morning', 'good evening'], -# 'answer': 'hi!', -# 'score': 80.0, -# 'id': 1, -# 'source': 'QnAMaker.tsv', -# 'metadata': [] -# } -# ], -# 'debugInfo': None -# } - -# my_first_ans = qna_ressy['answers'][0] - -# my_query = QueryResult(**my_first_ans) - -# print(my_query) \ No newline at end of file + return headers + \ No newline at end of file From 1ebd1e08b19cb400317ae28845fed094564a3af4 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Fri, 26 Apr 2019 07:06:02 -0700 Subject: [PATCH 47/73] removed ACTIVITY constant I had for testing --- libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index da3a07fc3..7cd22e5d8 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -20,16 +20,6 @@ QNAMAKER_TRACE_NAME = 'QnAMaker' QNAMAKER_TRACE_LABEL = 'QnAMaker Trace' -ACTIVITY = Activity(id='1234', - type='message', - text='up', - from_property=ChannelAccount(id='user', name='User Name'), - recipient=ChannelAccount(id='bot', name='Bot Name'), - conversation=ConversationAccount(id='convo', name='Convo Name'), - channel_id='UnitTest', - service_url='https://example.org' - ) - class Metadata: def __init__(self, name, value): self.name = name From 170029b631d48e228c2ac0cd21375f1689f72ca5 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Fri, 26 Apr 2019 08:27:28 -0700 Subject: [PATCH 48/73] reorganized qna classes into separate files --- .../botbuilder/ai/qna/__init__.py | 15 ++- .../botbuilder/ai/qna/metadata.py | 7 ++ .../botbuilder/ai/qna/qnamaker.py | 92 ++----------------- .../botbuilder/ai/qna/qnamaker_endpoint.py | 8 ++ .../botbuilder/ai/qna/qnamaker_options.py | 11 +++ .../ai/qna/qnamaker_telemetry_client.py | 26 ++++++ .../botbuilder/ai/qna/qnamaker_trace_info.py | 23 +++++ .../botbuilder/ai/qna/query_result.py | 13 +++ libraries/botbuilder-ai/run_test.cmd | 27 ++++++ libraries/botbuilder-ai/tests/qna/__init__.py | 0 libraries/botbuilder-ai/tests/qna/test_qna.py | 90 ++++++++++++++++++ libraries/botbuilder-azure/setup.py | 4 +- .../botbuilder/core/__init__.py | 3 +- 13 files changed, 229 insertions(+), 90 deletions(-) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/metadata.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_telemetry_client.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_trace_info.py create mode 100644 libraries/botbuilder-ai/botbuilder/ai/qna/query_result.py create mode 100644 libraries/botbuilder-ai/run_test.cmd create mode 100644 libraries/botbuilder-ai/tests/qna/__init__.py create mode 100644 libraries/botbuilder-ai/tests/qna/test_qna.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py index bf03f403e..42c6bdcb4 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py @@ -1,10 +1,19 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - +from .metadata import Metadata +from .query_result import QueryResult from .qnamaker import QnAMaker +from .qnamaker_endpoint import QnAMakerEndpoint +from .qnamaker_options import QnAMakerOptions +from .qnamaker_telemetry_client import QnAMakerTelemetryClient from .qna_telemetry_constants import QnATelemetryConstants __all__ = [ - "QnAMaker", - "QnATelemetryConstants" + 'Metadata', + 'QueryResult', + 'QnAMaker', + 'QnAMakerEndpoint', + 'QnAMakerOptions', + 'QnAMakerTelemetryClient', + 'QnATelemetryConstants', ] \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/metadata.py b/libraries/botbuilder-ai/botbuilder/ai/qna/metadata.py new file mode 100644 index 000000000..aac2b264a --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/metadata.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +class Metadata: + def __init__(self, name, value): + self.name = name + self.value = value diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index 7cd22e5d8..b4d5b7a98 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -10,97 +10,19 @@ from enum import Enum import asyncio -from abc import ABC, abstractmethod -# from . import( -# QnATelemetryConstants -# ) +from .metadata import Metadata +from .query_result import QueryResult +from .qnamaker_endpoint import QnAMakerEndpoint +from .qnamaker_options import QnAMakerOptions +from .qnamaker_telemetry_client import QnAMakerTelemetryClient +from .qna_telemetry_constants import QnATelemetryConstants +from .qnamaker_trace_info import QnAMakerTraceInfo QNAMAKER_TRACE_TYPE = 'https://www.qnamaker.ai/schemas/trace' QNAMAKER_TRACE_NAME = 'QnAMaker' QNAMAKER_TRACE_LABEL = 'QnAMaker Trace' -class Metadata: - def __init__(self, name, value): - self.name = name - self.value = value - -class QueryResult: - def __init__(self, questions: str, answer: str, score: float, metadata: [Metadata], source: str, id: int): - self.questions = questions, - self.answer = answer, - self.score = score, - self.metadata = Metadata, - self.source = source - self.id = id - -class QnAMakerEndpoint: - def __init__(self, knowledge_base_id: str, endpoint_key: str, host: str): - self.knowledge_base_id = knowledge_base_id - self.endpoint_key = endpoint_key - self.host = host - -# figure out if 300 milliseconds is ok for python requests library...or 100000 -class QnAMakerOptions: - def __init__(self, score_threshold: float = 0.0, timeout: int = 0, top: int = 0, strict_filters: [Metadata] = []): - self.score_threshold = score_threshold - self.timeout = timeout - self.top = top - self.strict_filters = strict_filters - -class QnATelemetryConstants(str, Enum): - """ - The IBotTelemetryClient event and property names that logged by default. - """ - - qna_message_event = 'QnaMessage' - """Event name""" - knowledge_base_id_property = 'knowledgeBaseId' - answer_property = 'answer' - article_found_property = 'articleFound' - channel_id_property = 'channelId' - conversation_id_property = 'conversationId' - question_property = 'question' - matched_question_property = 'matchedQuestion' - question_id_property = 'questionId' - score_metric = 'score' - username_property = 'username' - -class QnAMakerTelemetryClient(ABC): - def __init__( - self, - log_personal_information: bool, - telemetry_client: BotTelemetryClient - ): - self.log_personal_information = log_personal_information, - self.telemetry_client = telemetry_client - - @abstractmethod - def get_answers( - self, - context: TurnContext, - options: QnAMakerOptions = None, - telemetry_properties: Dict[str,str] = None, - telemetry_metrics: Dict[str, float] = None - ): - raise NotImplementedError('QnAMakerTelemetryClient.get_answers(): is not implemented.') - -class QnAMakerTraceInfo: - def __init__( - self, - message: Activity, - query_results: [QueryResult], - knowledge_base_id, - score_threshold, - top, - strict_filters - ): - self.message = message, - self.query_results = query_results, - self.knowledge_base_id = knowledge_base_id, - self.score_threshold = score_threshold, - self.top = top, - self.strict_filters = strict_filters class QnAMaker(QnAMakerTelemetryClient): def __init__( diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py new file mode 100644 index 000000000..7888fad20 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +class QnAMakerEndpoint: + def __init__(self, knowledge_base_id: str, endpoint_key: str, host: str): + self.knowledge_base_id = knowledge_base_id + self.endpoint_key = endpoint_key + self.host = host \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py new file mode 100644 index 000000000..0fdeb075e --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from .metadata import Metadata + +# figure out if 300 milliseconds is ok for python requests library...or 100000 +class QnAMakerOptions: + def __init__(self, score_threshold: float = 0.0, timeout: int = 0, top: int = 0, strict_filters: [Metadata] = []): + self.score_threshold = score_threshold + self.timeout = timeout + self.top = top + self.strict_filters = strict_filters diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_telemetry_client.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_telemetry_client.py new file mode 100644 index 000000000..9dccf2f55 --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_telemetry_client.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod +from botbuilder.core import BotTelemetryClient, TurnContext +from .qnamaker_options import QnAMakerOptions +from typing import Dict + +class QnAMakerTelemetryClient(ABC): + def __init__( + self, + log_personal_information: bool, + telemetry_client: BotTelemetryClient + ): + self.log_personal_information = log_personal_information, + self.telemetry_client = telemetry_client + + @abstractmethod + def get_answers( + self, + context: TurnContext, + options: QnAMakerOptions = None, + telemetry_properties: Dict[str,str] = None, + telemetry_metrics: Dict[str, float] = None + ): + raise NotImplementedError('QnAMakerTelemetryClient.get_answers(): is not implemented.') \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_trace_info.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_trace_info.py new file mode 100644 index 000000000..19a28d89c --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_trace_info.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.schema import Activity +from .query_result import QueryResult + +# Should we set the options=None in TraceInfo? (not optional in node) +class QnAMakerTraceInfo: + def __init__( + self, + message: Activity, + query_results: [QueryResult], + knowledge_base_id, + score_threshold, + top, + strict_filters + ): + self.message = message, + self.query_results = query_results, + self.knowledge_base_id = knowledge_base_id, + self.score_threshold = score_threshold, + self.top = top, + self.strict_filters = strict_filters \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/query_result.py b/libraries/botbuilder-ai/botbuilder/ai/qna/query_result.py new file mode 100644 index 000000000..3069ec50a --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/query_result.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .metadata import Metadata + +class QueryResult: + def __init__(self, questions: str, answer: str, score: float, metadata: [Metadata], source: str, id: int): + self.questions = questions, + self.answer = answer, + self.score = score, + self.metadata = Metadata, + self.source = source + self.id = id \ No newline at end of file diff --git a/libraries/botbuilder-ai/run_test.cmd b/libraries/botbuilder-ai/run_test.cmd new file mode 100644 index 000000000..eaf5d3bfa --- /dev/null +++ b/libraries/botbuilder-ai/run_test.cmd @@ -0,0 +1,27 @@ +@ECHO OFF + + +cd C:\Users\v-asho\Desktop\Python\botbuilder-python\libraries\botbuilder-ai + +python -m compileall . +IF %ERRORLEVEL% NEQ 0 ( + ECHO [Error] build failed! + exit /b %errorlevel% +) + +python -O -m compileall . +IF %ERRORLEVEL% NEQ 0 ( + ECHO [Error] build failed! + exit /b %errorlevel% +) + +pip install . +IF %ERRORLEVEL% NEQ 0 ( + ECHO [Error] DIALOGS Install failed! + exit /b %errorlevel% +) + +python -m unittest discover ./tests +IF %ERRORLEVEL% NEQ 0 ( + ECHO [Error] Test failed! + exit /b %errorlevel% diff --git a/libraries/botbuilder-ai/tests/qna/__init__.py b/libraries/botbuilder-ai/tests/qna/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py new file mode 100644 index 000000000..a75594eb5 --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import aiounittest +from typing import List, Tuple +from uuid import uuid4 +from botbuilder.schema import Activity, ChannelAccount, ResourceResponse, ConversationAccount +from botbuilder.core import BotAdapter, BotTelemetryClient, NullTelemetryClient, TurnContext +from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions +# DELETE YO +ACTIVITY = Activity(id='1234', + type='message', + text='up', + from_property=ChannelAccount(id='user', name='User Name'), + recipient=ChannelAccount(id='bot', name='Bot Name'), + conversation=ConversationAccount(id='convo', name='Convo Name'), + channel_id='UnitTest', + service_url='https://example.org' + ) + +class SimpleAdapter(BotAdapter): + async def send_activities(self, context, activities): + responses = [] + for (idx, activity) in enumerate(activities): + responses.append(ResourceResponse(id='5678')) + return responses + + async def update_activity(self, context, activity): + assert context is not None + assert activity is not None + + async def delete_activity(self, context, reference): + assert context is not None + assert reference is not None + assert reference.activity_id == '1234' + +class QnaApplicationTest(aiounittest.AsyncTestCase): + + async def test_initial_test(self): + adapter = SimpleAdapter() + context = TurnContext(adapter, ACTIVITY) + + endpointy = QnAMakerEndpoint('a090f9f3-2f8e-41d1-a581-4f7a49269a0c', '4a439d5b-163b-47c3-b1d1-168cc0db5608', 'https://ashleyNlpBot1-qnahost.azurewebsites.net/qnamaker') + qna = QnAMaker(endpointy) + optionsies = QnAMakerOptions(top=3, strict_filters=[{'name': 'movie', 'value': 'disney'}]) + + r = await qna.get_answers(context, optionsies) + + # loop = asyncio.get_event_loop() + # r = loop.run_until_complete((qna.get_answers(context, optionsies))) + # loop.close() + + # result = qna.get_answers(context) + # print(type(result)) + print(r) + + print('donesies!') + + + # context2 = TurnContext(adapter, ACTIVITY) + # print(context2.__dict__.update({'test': '1'})) + + # qna_ressy = { + # 'answers': [ + # { + # 'questions': ['hi', 'greetings', 'good morning', 'good evening'], + # 'answer': 'Hello!', + # 'score': 100.0, + # 'id': 1, + # 'source': 'QnAMaker.tsv', + # 'metadata': [] + # }, + # { + # 'questions': ['hi', 'greetings', 'good morning', 'good evening'], + # 'answer': 'hi!', + # 'score': 80.0, + # 'id': 1, + # 'source': 'QnAMaker.tsv', + # 'metadata': [] + # } + # ], + # 'debugInfo': None + # } + + # my_first_ans = qna_ressy['answers'][0] + + # my_query = QueryResult(**my_first_ans) + + # print(my_query) diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index 29a129fe4..ad3ebeb45 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -7,6 +7,7 @@ REQUIRES = ['azure-cosmos==3.0.0', 'botbuilder-schema>=4.0.0.a6', 'botframework-connector>=4.0.0.a6'] +TEST_REQUIRES = ['aiounittests==1.1.0'] root = os.path.abspath(os.path.dirname(__file__)) @@ -26,7 +27,8 @@ long_description=package_info['__summary__'], license=package_info['__license__'], packages=['botbuilder.azure'], - install_requires=REQUIRES, + install_requires=REQUIRES + TEST_REQUIRES, + tests_require=TEST_REQUIRES, classifiers=[ 'Programming Language :: Python :: 3.6', 'Intended Audience :: Developers', diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index 99bc69f68..80ac54c22 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -31,6 +31,7 @@ 'BotFrameworkAdapter', 'BotFrameworkAdapterSettings', 'BotState', + 'BotTelemetryClient', 'calculate_change_hash', 'CardFactory', 'ConversationState', @@ -38,7 +39,7 @@ 'MessageFactory', 'Middleware', 'MiddlewareSet', - 'NullBotTelemetryClient', + 'NullTelemetryClient', 'StatePropertyAccessor', 'StatePropertyInfo', 'Storage', From c4703363d8d62df8024942651c5b77b50193e636 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Fri, 26 Apr 2019 08:29:59 -0700 Subject: [PATCH 49/73] removed test case --- libraries/botbuilder-ai/tests/qna/test_qna.py | 53 +------------------ 1 file changed, 1 insertion(+), 52 deletions(-) diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index a75594eb5..ec91f0208 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -36,55 +36,4 @@ async def delete_activity(self, context, reference): assert reference.activity_id == '1234' class QnaApplicationTest(aiounittest.AsyncTestCase): - - async def test_initial_test(self): - adapter = SimpleAdapter() - context = TurnContext(adapter, ACTIVITY) - - endpointy = QnAMakerEndpoint('a090f9f3-2f8e-41d1-a581-4f7a49269a0c', '4a439d5b-163b-47c3-b1d1-168cc0db5608', 'https://ashleyNlpBot1-qnahost.azurewebsites.net/qnamaker') - qna = QnAMaker(endpointy) - optionsies = QnAMakerOptions(top=3, strict_filters=[{'name': 'movie', 'value': 'disney'}]) - - r = await qna.get_answers(context, optionsies) - - # loop = asyncio.get_event_loop() - # r = loop.run_until_complete((qna.get_answers(context, optionsies))) - # loop.close() - - # result = qna.get_answers(context) - # print(type(result)) - print(r) - - print('donesies!') - - - # context2 = TurnContext(adapter, ACTIVITY) - # print(context2.__dict__.update({'test': '1'})) - - # qna_ressy = { - # 'answers': [ - # { - # 'questions': ['hi', 'greetings', 'good morning', 'good evening'], - # 'answer': 'Hello!', - # 'score': 100.0, - # 'id': 1, - # 'source': 'QnAMaker.tsv', - # 'metadata': [] - # }, - # { - # 'questions': ['hi', 'greetings', 'good morning', 'good evening'], - # 'answer': 'hi!', - # 'score': 80.0, - # 'id': 1, - # 'source': 'QnAMaker.tsv', - # 'metadata': [] - # } - # ], - # 'debugInfo': None - # } - - # my_first_ans = qna_ressy['answers'][0] - - # my_query = QueryResult(**my_first_ans) - - # print(my_query) + pass \ No newline at end of file From 2b1510bd1bfc1b8e579690b6a4b92a809d14e8f6 Mon Sep 17 00:00:00 2001 From: congysu Date: Fri, 26 Apr 2019 11:15:35 -0700 Subject: [PATCH 50/73] add luis recognizer init test --- .../botbuilder/ai/luis/__init__.py | 2 ++ .../botbuilder/ai/luis/luis_recognizer.py | 21 +++++++++++-------- .../botbuilder/ai/luis/luis_util.py | 2 +- .../tests/luis/luis_recognizer_test.py | 18 ++++++++++++++++ 4 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py index d2ab8f8ff..7bbeb68cd 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py @@ -6,11 +6,13 @@ from .luis_prediction_options import LuisPredictionOptions from .luis_telemetry_constants import LuisTelemetryConstants from .recognizer_result import RecognizerResult +from .luis_recognizer import LuisRecognizer __all__ = [ "IntentScore", "LuisApplication", "LuisPredictionOptions", + "LuisRecognizer", "LuisTelemetryConstants", "RecognizerResult", ] diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 6e57aecc7..da755e098 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import json -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Union from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient from azure.cognitiveservices.language.luis.runtime.models import LuisResult @@ -39,7 +39,7 @@ class LuisRecognizer(object): def __init__( self, - application: LuisApplication, + application: Union[LuisApplication, str], prediction_options: LuisPredictionOptions = None, include_api_results: bool = False, ): @@ -54,19 +54,22 @@ def __init__( :raises TypeError: """ - if application is None: - raise TypeError("LuisRecognizer.__init__(): application cannot be None.") - self._application = application + if isinstance(application, LuisApplication): + self._application = application + elif isinstance(application, str): + self._application = LuisApplication.from_application_endpoint(application) + else: + raise TypeError("LuisRecognizer.__init__(): application is not an instance of LuisApplication or str.") self._options = prediction_options or LuisPredictionOptions() self._include_api_results = include_api_results - self._telemetry_client = self._options.TelemetryClient - self._log_personal_information = self._options.LogPersonalInformation + self._telemetry_client = self._options.telemetry_client + self._log_personal_information = self._options.log_personal_information - credentials = CognitiveServicesCredentials(application.EndpointKey) - self._runtime = LUISRuntimeClient(application.endpoint, credentials) + credentials = CognitiveServicesCredentials(self._application.endpoint_key) + self._runtime = LUISRuntimeClient(self._application.endpoint, credentials) @property def log_personal_information(self) -> bool: diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index fc4865d93..fd3c3e381 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -80,7 +80,7 @@ def extract_entities_and_metadata( return entities_and_metadata @staticmethod - def number(value: object) -> Union(int, float): + def number(value: object) -> Union[int, float]: if value is None: return None diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py new file mode 100644 index 000000000..3975f1643 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -0,0 +1,18 @@ +import unittest + +from botbuilder.ai.luis import LuisRecognizer + + +class LuisRecognizerTest(unittest.TestCase): + def test_luis_recognizer_construction(self): + # Arrange + endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q=" + + # Act + recognizer = LuisRecognizer(endpoint) + + # Assert + app = recognizer._application + self.assertEqual("b31aeaf3-3511-495b-a07f-571fc873214b", app.application_id) + self.assertEqual("048ec46dc58e495482b0c447cfdbd291", app.endpoint_key) + self.assertEqual("https://westus.api.cognitive.microsoft.com", app.endpoint) From 387cfd484ec7b5876682919a915e5bdb18ed8ee3 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Fri, 26 Apr 2019 13:11:07 -0700 Subject: [PATCH 51/73] qna telemetry --- .../botbuilder/ai/qna/__init__.py | 2 + .../botbuilder/ai/qna/qnamaker.py | 88 ++++++++++++++++--- libraries/botbuilder-ai/tests/qna/test_qna.py | 4 +- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py index 42c6bdcb4..58140f73f 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py @@ -7,6 +7,7 @@ from .qnamaker_options import QnAMakerOptions from .qnamaker_telemetry_client import QnAMakerTelemetryClient from .qna_telemetry_constants import QnATelemetryConstants +from .qnamaker_trace_info import QnAMakerTraceInfo __all__ = [ 'Metadata', @@ -15,5 +16,6 @@ 'QnAMakerEndpoint', 'QnAMakerOptions', 'QnAMakerTelemetryClient', + 'QnAMakerTraceInfo', 'QnATelemetryConstants', ] \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index b4d5b7a98..992800f59 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -4,10 +4,9 @@ from botbuilder.schema import Activity, ChannelAccount, ResourceResponse, ConversationAccount from botbuilder.core import BotAdapter, BotTelemetryClient, NullTelemetryClient, TurnContext # import http.client, urllib.parse, json, time, urllib.request -import json, requests from copy import copy -from typing import Dict, List, Tuple -from enum import Enum +import json, requests +from typing import Dict, List, NamedTuple import asyncio @@ -19,10 +18,23 @@ from .qna_telemetry_constants import QnATelemetryConstants from .qnamaker_trace_info import QnAMakerTraceInfo +# from . import ( +# Metadata, +# QueryResult, +# QnAMakerEndpoint, +# QnAMakerOptions, +# QnAMakerTelemetryClient, +# QnATelemetryConstants, +# QnAMakerTraceInfo +# ) + QNAMAKER_TRACE_TYPE = 'https://www.qnamaker.ai/schemas/trace' QNAMAKER_TRACE_NAME = 'QnAMaker' QNAMAKER_TRACE_LABEL = 'QnAMaker Trace' +class EventData(NamedTuple): + properties: Dict[str, str] + metrics: Dict[str, float] class QnAMaker(QnAMakerTelemetryClient): def __init__( @@ -82,24 +94,77 @@ def telemetry_client(self, value: BotTelemetryClient): self._telemetry_client = value - async def on_qna_result(self): - # event_data = await fill_qna_event() - pass + async def on_qna_result( + self, + query_results: [QueryResult], + turn_context: TurnContext, + telemetry_properties: Dict[str, str] = None, + telemetry_metrics: Dict[str, float] = None + ): + event_data = await self.fill_qna_event(query_results, turn_context, telemetry_properties, telemetry_metrics) - async def fill_qna_event( + # Track the event. + self.telemetry_client.track_event( + name = QnATelemetryConstants.qna_message_event, + properties = event_data.properties, + measurements = event_data.metrics + ) + + def fill_qna_event( self, query_results: [QueryResult], turn_context: TurnContext, telemetry_properties: Dict[str,str] = None, telemetry_metrics: Dict[str,float] = None - ) -> Tuple[ Dict[str, str], Dict[str,int] ]: + ) -> EventData: properties: Dict[str,str] = dict() metrics: Dict[str, float] = dict() properties[QnATelemetryConstants.knowledge_base_id_property] = self._endpoint.knowledge_base_id - pass + text: str = turn_context.activity.text + userName: str = turn_context.activity.from_property.name + + # Use the LogPersonalInformation flag to toggle logging PII data; text is a common example. + if self.log_personal_information: + if text: + properties[QnATelemetryConstants.question_property] = text + + if userName: + properties[QnATelemetryConstants.username_property] = userName + + # Fill in Qna Results (found or not). + if len(query_results) > 0: + query_result = query_results[0] + + result_properties = { + QnATelemetryConstants.matched_question_property: json.dumps(query_result.questions), + QnATelemetryConstants.question_id_property: str(query_result.id), + QnATelemetryConstants.answer_property: query_result.answer, + QnATelemetryConstants.score_metric: query_result.score, + QnATelemetryConstants.article_found_property: 'true' + } + properties.update(result_properties) + else: + no_match_properties = { + QnATelemetryConstants.matched_question_property : 'No Qna Question matched', + QnATelemetryConstants.question_id_property : 'No Qna Question Id matched', + QnATelemetryConstants.answer_property : 'No Qna Answer matched', + QnATelemetryConstants.article_found_property : 'false' + } + + properties.update(no_match_properties) + + # Additional Properties can override "stock" properties. + if telemetry_properties: + properties.update(telemetry_properties) + + # Additional Metrics can override "stock" metrics. + if telemetry_metrics: + metrics.update(telemetry_metrics) + + return EventData(properties=properties, metrics=metrics) async def get_answers( self, @@ -139,7 +204,10 @@ def hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: hydrated_options = copy(self._options) if query_options: - if (query_options.score_threshold != hydrated_options.score_threshold and query_options.score_threshold): + if ( + query_options.score_threshold != hydrated_options.score_threshold + and query_options.score_threshold + ): hydrated_options.score_threshold = query_options.score_threshold if (query_options.top != hydrated_options.top and query_options.top != 0): diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index ec91f0208..6b0f6ad9c 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -36,4 +36,6 @@ async def delete_activity(self, context, reference): assert reference.activity_id == '1234' class QnaApplicationTest(aiounittest.AsyncTestCase): - pass \ No newline at end of file + + async def test_initial_test(self): + pass \ No newline at end of file From 328c745d490970feaabaf45bc19677181f90cfee Mon Sep 17 00:00:00 2001 From: congysu Date: Fri, 26 Apr 2019 14:57:56 -0700 Subject: [PATCH 52/73] add test with mock and patch * enable unit test for the recognizer with py's built-in mock.patch * mock/patch the requests.session.send and msrest.*.Deserializer input for azure's *RuntimeClient --- .../ai/luis/luis_prediction_options.py | 4 +- .../botbuilder/ai/luis/luis_recognizer.py | 37 ++--- .../botbuilder/ai/luis/luis_util.py | 2 +- .../botbuilder/ai/luis/recognizer_result.py | 16 ++- .../tests/luis/luis_recognizer_test.py | 136 +++++++++++++++++- 5 files changed, 162 insertions(+), 33 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py index 68ea803c9..c6f91a724 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py @@ -9,14 +9,14 @@ class LuisPredictionOptions(object): Optional parameters for a LUIS prediction request. """ - def __init__(self): + def __init__(self, timeout: float = 100000): self._bing_spell_check_subscription_key: str = None self._include_all_intents: bool = None self._include_instance_data: bool = None self._log: bool = None self._spell_check: bool = None self._staging: bool = None - self._timeout: float = 100000 + self._timeout: float = timeout self._timezone_offset: float = None self._telemetry_client: BotTelemetryClient = NullTelemetryClient() self._log_personal_information: bool = False diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index da755e098..66b74edb9 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -59,7 +59,9 @@ def __init__( elif isinstance(application, str): self._application = LuisApplication.from_application_endpoint(application) else: - raise TypeError("LuisRecognizer.__init__(): application is not an instance of LuisApplication or str.") + raise TypeError( + "LuisRecognizer.__init__(): application is not an instance of LuisApplication or str." + ) self._options = prediction_options or LuisPredictionOptions() @@ -146,7 +148,7 @@ def top_intent( return top_intent or default_intent - async def recognize( + def recognize( self, turn_context: TurnContext, telemetry_properties: Dict[str, str] = None, @@ -164,11 +166,11 @@ async def recognize( :rtype: RecognizerResult """ - return await self._recognize_internal( + return self._recognize_internal( turn_context, telemetry_properties, telemetry_metrics ) - async def on_recognizer_result( + def on_recognizer_result( self, recognizer_result: RecognizerResult, turn_context: TurnContext, @@ -187,7 +189,7 @@ async def on_recognizer_result( :param telemetry_metrics: Dict[str, float], optional """ - properties = await self.fill_luis_event_properties( + properties = self.fill_luis_event_properties( recognizer_result, turn_context, telemetry_properties ) @@ -205,7 +207,7 @@ def _get_top_k_intent_score( if intent_names: intent_name = intent_names[0] if intents[intent_name] is not None: - intent_score = "{:.2f}".format(intents[intent_name]) + intent_score = "{:.2f}".format(intents[intent_name].score) return intent_name, intent_score @@ -244,12 +246,12 @@ def fill_luis_event_properties( # Add the intent score and conversation id properties properties: Dict[str, str] = { - LuisTelemetryConstants.application_id_property: self._application.ApplicationId, + LuisTelemetryConstants.application_id_property: self._application.application_id, LuisTelemetryConstants.intent_property: intent_name, LuisTelemetryConstants.intent_score_property: intent_score, LuisTelemetryConstants.intent2_property: intent2_name, LuisTelemetryConstants.intent_score2_property: intent2_score, - LuisTelemetryConstants.from_id_property: turn_context.Activity.From.Id, + LuisTelemetryConstants.from_id_property: turn_context.activity.from_property.id, } sentiment = recognizer_result.properties.get("sentiment") @@ -280,7 +282,7 @@ def fill_luis_event_properties( return properties - async def _recognize_internal( + def _recognize_internal( self, turn_context: TurnContext, telemetry_properties: Dict[str, str], @@ -301,7 +303,7 @@ async def _recognize_internal( text=utterance, intents={"": IntentScore(score=1.0)}, entities={} ) else: - luis_result = await self._runtime.prediction.resolve( + luis_result = self._runtime.prediction.resolve( self._application.application_id, utterance, timezoneOffset=self._options.timezone_offset, @@ -329,21 +331,8 @@ async def _recognize_internal( recognizer_result.properties["luisResult"] = luis_result # Log telemetry - await self.on_recognizer_result( + self.on_recognizer_result( recognizer_result, turn_context, telemetry_properties, telemetry_metrics ) - trace_info = { - "recognizerResult": recognizer_result, - "luisModel": {"ModelID": self._application.application_id}, - "luisOptions": self._options, - "luisResult": luis_result, - } - - await turn_context.trace_activity_async( - "LuisRecognizer", - trace_info, - LuisRecognizer.luis_trace_type, - LuisRecognizer.luis_trace_label, - ) return recognizer_result diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index fd3c3e381..b80c6d24b 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -160,7 +160,7 @@ def extract_entity_metadata(entity: EntityModel) -> Dict: @staticmethod def extract_normalized_entity_name(entity: EntityModel) -> str: # Type::Role -> Role - type = entity.Type.split(":")[-1] + type = entity.type.split(":")[-1] if type.startswith("builtin.datetimeV2."): type = "datetime" diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py index 003624f6f..830fc7e31 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py @@ -11,11 +11,17 @@ class RecognizerResult: Contains recognition results generated by a recognizer. """ - def __init__(self, text:str=None, altered_text:str=None, intents: Dict[str, IntentScore]=None, entities :Dict=None): - self._text: str = None - self._altered_text: str = None - self._intents: Dict[str, IntentScore] = None - self._entities: Dict = None + def __init__( + self, + text: str = None, + altered_text: str = None, + intents: Dict[str, IntentScore] = None, + entities: Dict = None, + ): + self._text: str = text + self._altered_text: str = altered_text + self._intents: Dict[str, IntentScore] = intents + self._entities: Dict = entities self._properties: Dict[str, object] = {} @property diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index 3975f1643..51fc427c7 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -1,9 +1,27 @@ +import json import unittest +from unittest.mock import Mock, patch -from botbuilder.ai.luis import LuisRecognizer +import requests +from msrest import Deserializer +from requests.models import Response + +from botbuilder.ai.luis import LuisApplication, LuisPredictionOptions, LuisRecognizer +from botbuilder.core import TurnContext +from botbuilder.core.adapters import TestAdapter +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) class LuisRecognizerTest(unittest.TestCase): + _luisAppId: str = "b31aeaf3-3511-495b-a07f-571fc873214b" + _subscriptionKey: str = "048ec46dc58e495482b0c447cfdbd291" + _endpoint: str = "https://westus.api.cognitive.microsoft.com" + def test_luis_recognizer_construction(self): # Arrange endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q=" @@ -16,3 +34,119 @@ def test_luis_recognizer_construction(self): self.assertEqual("b31aeaf3-3511-495b-a07f-571fc873214b", app.application_id) self.assertEqual("048ec46dc58e495482b0c447cfdbd291", app.endpoint_key) self.assertEqual("https://westus.api.cognitive.microsoft.com", app.endpoint) + + def test_none_endpoint(self): + # Arrange + my_app = LuisApplication( + LuisRecognizerTest._luisAppId, + LuisRecognizerTest._subscriptionKey, + endpoint=None, + ) + + # Assert + recognizer = LuisRecognizer(my_app, prediction_options=None) + + # Assert + app = recognizer._application + self.assertEqual("https://westus.api.cognitive.microsoft.com", app.endpoint) + + def test_empty_endpoint(self): + # Arrange + my_app = LuisApplication( + LuisRecognizerTest._luisAppId, + LuisRecognizerTest._subscriptionKey, + endpoint="", + ) + + # Assert + recognizer = LuisRecognizer(my_app, prediction_options=None) + + # Assert + app = recognizer._application + self.assertEqual("https://westus.api.cognitive.microsoft.com", app.endpoint) + + def test_luis_recognizer_none_luis_app_arg(self): + with self.assertRaises(TypeError): + LuisRecognizer(application=None) + + def test_single_intent_simply_entity(self): + utterance: str = "My name is Emad" + response_str: str = """{ + "query": "my name is Emad", + "topScoringIntent": { + "intent": "SpecifyName", + "score": 0.8785189 + }, + "intents": [ + { + "intent": "SpecifyName", + "score": 0.8785189 + } + ], + "entities": [ + { + "entity": "emad", + "type": "Name", + "startIndex": 11, + "endIndex": 14, + "score": 0.8446753 + } + ] + }""" + response_json = json.loads(response_str) + + my_app = LuisApplication( + LuisRecognizerTest._luisAppId, + LuisRecognizerTest._subscriptionKey, + endpoint="", + ) + recognizer = LuisRecognizer(my_app, prediction_options=None) + context = LuisRecognizerTest._get_context(utterance) + response = Mock(spec=Response) + response.status_code = 200 + response.headers = {} + response.reason = "" + with patch("requests.Session.send", return_value=response): + with patch( + "msrest.serialization.Deserializer._unpack_content", + return_value=response_json, + ): + result = recognizer.recognize(context) + self.assertIsNotNone(result) + self.assertIsNone(result.altered_text) + self.assertEqual(utterance, result.text) + self.assertIsNotNone(result.intents) + self.assertEqual(1, len(result.intents)) + self.assertIsNotNone(result.intents["SpecifyName"]) + self.assert_score(result.intents["SpecifyName"].score) + self.assertIsNotNone(result.entities) + self.assertIsNotNone(result.entities["Name"]) + self.assertEqual("emad", result.entities["Name"][0]) + self.assertIsNotNone(result.entities["$instance"]) + self.assertIsNotNone(result.entities["$instance"]["Name"]) + self.assertEqual(11, result.entities["$instance"]["Name"][0]["startIndex"]) + self.assertEqual(15, result.entities["$instance"]["Name"][0]["endIndex"]) + self.assert_score(result.entities["$instance"]["Name"][0]["score"]) + + def assert_score(self, score: float): + self.assertTrue(score >= 0) + self.assertTrue(score <= 1) + + @classmethod + def _get_luis_recognizer( + cls, verbose: bool = False, options: LuisPredictionOptions = None + ) -> LuisRecognizer: + luis_app = LuisApplication(cls._luisAppId, cls._subscriptionKey, cls._endpoint) + return LuisRecognizer(luis_app, options, verbose) + + @staticmethod + def _get_context(utterance: str) -> TurnContext: + test_adapter = TestAdapter() + activity = Activity( + type=ActivityTypes.message, + text=utterance, + conversation=ConversationAccount(), + recipient=ChannelAccount(), + from_property=ChannelAccount(), + ) + return TurnContext(test_adapter, activity) From 93716790540984cb230f70bb2955859dbc2e3954 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Fri, 26 Apr 2019 16:43:49 -0700 Subject: [PATCH 53/73] Now raises errors and added docstrings --- .../botbuilder/ai/qna/qnamaker.py | 75 +++++++++++++------ .../botbuilder/ai/qna/qnamaker_endpoint.py | 12 ++- libraries/botbuilder-ai/tests/qna/test_qna.py | 4 +- 3 files changed, 67 insertions(+), 24 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index 992800f59..22b6a62c8 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -1,15 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.schema import Activity, ChannelAccount, ResourceResponse, ConversationAccount -from botbuilder.core import BotAdapter, BotTelemetryClient, NullTelemetryClient, TurnContext -# import http.client, urllib.parse, json, time, urllib.request +from botbuilder.schema import Activity +from botbuilder.core import BotTelemetryClient, NullTelemetryClient, TurnContext from copy import copy import json, requests from typing import Dict, List, NamedTuple -import asyncio - from .metadata import Metadata from .query_result import QueryResult from .qnamaker_endpoint import QnAMakerEndpoint @@ -28,30 +25,41 @@ # QnAMakerTraceInfo # ) -QNAMAKER_TRACE_TYPE = 'https://www.qnamaker.ai/schemas/trace' QNAMAKER_TRACE_NAME = 'QnAMaker' QNAMAKER_TRACE_LABEL = 'QnAMaker Trace' +QNAMAKER_TRACE_TYPE = 'https://www.qnamaker.ai/schemas/trace' class EventData(NamedTuple): properties: Dict[str, str] metrics: Dict[str, float] class QnAMaker(QnAMakerTelemetryClient): + """ + Class used to query a QnA Maker knowledge base for answers. + """ + def __init__( self, endpoint: QnAMakerEndpoint, - options: QnAMakerOptions = QnAMakerOptions(), + options: QnAMakerOptions = None, telemetry_client: BotTelemetryClient = None, log_personal_information: bool = None ): + if not isinstance(endpoint, QnAMakerEndpoint): + raise TypeError('QnAMaker.__init__(): endpoint is not an instance of QnAMakerEndpoint') + + if endpoint.host.endswith('v2.0'): + raise ValueError('v2.0 of QnA Maker service is no longer supported in the Bot Framework. Please upgrade your QnA Maker service at www.qnamaker.ai.') + self._endpoint = endpoint self._is_legacy_protocol: bool = self._endpoint.host.endswith('v3.0') - self._options: QnAMakerOptions = options + + self._options: QnAMakerOptions = options or QnAMakerOptions() + self.validate_options(self._options) + self._telemetry_client = telemetry_client or NullTelemetryClient() self._log_personal_information = log_personal_information or False - self.validate_options(self._options) - @property def log_personal_information(self) -> bool: """Gets a value indicating whether to log personal information that came from the user to telemetry. @@ -103,7 +111,6 @@ async def on_qna_result( ): event_data = await self.fill_qna_event(query_results, turn_context, telemetry_properties, telemetry_metrics) - # Track the event. self.telemetry_client.track_event( name = QnATelemetryConstants.qna_message_event, properties = event_data.properties, @@ -117,7 +124,14 @@ def fill_qna_event( telemetry_properties: Dict[str,str] = None, telemetry_metrics: Dict[str,float] = None ) -> EventData: - + """ + Fills the event properties and metrics for the QnaMessage event for telemetry. + + :return: A tuple of event data properties and metrics that will be sent to the BotTelemetryClient.track_event() method for the QnAMessage event. The properties and metrics returned the standard properties logged with any properties passed from the get_answers() method. + + :rtype: EventData + """ + properties: Dict[str,str] = dict() metrics: Dict[str, float] = dict() @@ -126,7 +140,7 @@ def fill_qna_event( text: str = turn_context.activity.text userName: str = turn_context.activity.from_property.name - # Use the LogPersonalInformation flag to toggle logging PII data; text is a common example. + # Use the LogPersonalInformation flag to toggle logging PII data; text and username are common examples. if self.log_personal_information: if text: properties[QnATelemetryConstants.question_property] = text @@ -145,6 +159,7 @@ def fill_qna_event( QnATelemetryConstants.score_metric: query_result.score, QnATelemetryConstants.article_found_property: 'true' } + properties.update(result_properties) else: no_match_properties = { @@ -153,7 +168,7 @@ def fill_qna_event( QnATelemetryConstants.answer_property : 'No Qna Answer matched', QnATelemetryConstants.article_found_property : 'false' } - + properties.update(no_match_properties) # Additional Properties can override "stock" properties. @@ -172,8 +187,16 @@ async def get_answers( options: QnAMakerOptions = None, telemetry_properties: Dict[str,str] = None, telemetry_metrics: Dict[str,int] = None - ): - # add timeout + ) -> [QueryResult]: + """ + Generates answers from the knowledge base. + + :return: A list of answers for the user's query, sorted in decreasing order of ranking score. + + :rtype: [QueryResult] + """ + + hydrated_options = self.hydrate_options(options) self.validate_options(hydrated_options) @@ -190,17 +213,24 @@ def validate_options(self, options: QnAMakerOptions): if not options.top: options.top = 1 - # write range error for if scorethreshold < 0 or > 1 + if options.score_threshold < 0 or options.score_threshold > 1: + raise ValueError('Score threshold should be a value between 0 and 1') - if not options.timeout: - options.timeout = 100000 # check timeout units in requests module - - # write range error for if top < 1 + if options.top < 1: + raise ValueError('QnAMakerOptions.top should be an integer greater than 0') if not options.strict_filters: options.strict_filters = [Metadata] def hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: + """ + Combines QnAMakerOptions passed into the QnAMaker constructor with the options passed as arguments into get_answers(). + + :return: QnAMakerOptions with options passed into constructor overwritten by new options passed into get_answers() + + :rtype: QnAMakerOptions + """ + hydrated_options = copy(self._options) if query_options: @@ -267,6 +297,8 @@ def format_qna_result(self, qna_result: requests.Response, options: QnAMakerOpti ] sorted_answers = sorted(answers_within_threshold, key = lambda ans: ans['score'], reverse = True) + # The old version of the protocol returns the id in a field called qnaId + # The following translates this old structure to the new if self._is_legacy_protocol: for answer in answers_within_threshold: answer['id'] = answer.pop('qnaId', None) @@ -286,4 +318,3 @@ def get_headers(self): # need user-agent header return headers - \ No newline at end of file diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py index 7888fad20..cdd7a8546 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py @@ -2,7 +2,17 @@ # Licensed under the MIT License. class QnAMakerEndpoint: + def __init__(self, knowledge_base_id: str, endpoint_key: str, host: str): + if not knowledge_base_id: + raise TypeError('QnAMakerEndpoint.knowledge_base_id cannot be empty.') + + if not endpoint_key: + raise TypeError('QnAMakerEndpoint.endpoint_key cannot be empty.') + + if not host: + raise TypeError('QnAMakerEndpoint.host cannot be empty.') + self.knowledge_base_id = knowledge_base_id - self.endpoint_key = endpoint_key + self.endpoint_key = endpoint_key self.host = host \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index 6b0f6ad9c..7cf19dc44 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -7,7 +7,9 @@ from uuid import uuid4 from botbuilder.schema import Activity, ChannelAccount, ResourceResponse, ConversationAccount from botbuilder.core import BotAdapter, BotTelemetryClient, NullTelemetryClient, TurnContext -from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions +from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions, QnATelemetryConstants + + # DELETE YO ACTIVITY = Activity(id='1234', type='message', From 60adb556be0e327b255ff4f747ef071753f71cac Mon Sep 17 00:00:00 2001 From: congysu Date: Sat, 27 Apr 2019 11:16:14 -0700 Subject: [PATCH 54/73] update to azure-cognitiveservices-language.* 0.2 * update setup and requirements * add get top intent for recognizer result * add more tests --- .../botbuilder/ai/luis/recognizer_result.py | 22 +- libraries/botbuilder-ai/requirements.txt | 7 +- libraries/botbuilder-ai/setup.py | 3 +- .../tests/luis/luis_recognizer_test.py | 45 + .../tests/luis/test_data/Composite1.json | 994 +++++++++++++++++ .../tests/luis/test_data/Composite2.json | 221 ++++ .../tests/luis/test_data/Composite3.json | 256 +++++ .../test_data/MultipleDateTimeEntities.json | 93 ++ .../MultipleIntents_CompositeEntityModel.json | 54 + ...ipleIntents_ListEntityWithMultiValues.json | 27 + ...ipleIntents_ListEntityWithSingleValue.json | 26 + ...tents_PrebuiltEntitiesWithMultiValues.json | 52 + .../MultipleIntents_PrebuiltEntity.json | 46 + .../tests/luis/test_data/Patterns.json | 169 +++ .../tests/luis/test_data/Prebuilt.json | 175 +++ .../test_data/SingleIntent_SimplyEntity.json | 22 + .../tests/luis/test_data/TraceActivity.json | 22 + .../tests/luis/test_data/Typed.json | 996 ++++++++++++++++++ .../tests/luis/test_data/TypedPrebuilt.json | 177 ++++ .../luis/test_data/V1DatetimeResolution.json | 19 + 20 files changed, 3418 insertions(+), 8 deletions(-) create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Composite1.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Composite2.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Composite3.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/MultipleDateTimeEntities.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_CompositeEntityModel.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_ListEntityWithMultiValues.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_ListEntityWithSingleValue.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_PrebuiltEntitiesWithMultiValues.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_PrebuiltEntity.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Patterns.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Prebuilt.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/SingleIntent_SimplyEntity.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/TraceActivity.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/Typed.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/TypedPrebuilt.json create mode 100644 libraries/botbuilder-ai/tests/luis/test_data/V1DatetimeResolution.json diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py index 830fc7e31..11db4b4ab 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Dict +from typing import Dict, NamedTuple, Tuple from . import IntentScore @@ -136,3 +136,23 @@ def properties(self, value: Dict[str, object]) -> None: """ self._properties = value + + def get_top_scoring_intent( + self + ) -> NamedTuple("TopIntent", intent=str, score=float): + """Return the top scoring intent and its score. + + :return: Intent and score. + :rtype: NamedTuple("TopIntent", intent=str, score=float) + """ + + if self.intents is None: + raise TypeError("result.intents can't be None") + + top_intent: Tuple[str, float] = ("", 0.0) + for intent_name, intent_score in self.intents.items(): + score = intent_score.score + if score > top_intent[1]: + top_intent = (intent_name, score) + + return top_intent diff --git a/libraries/botbuilder-ai/requirements.txt b/libraries/botbuilder-ai/requirements.txt index c713fd126..9ace9b6c4 100644 --- a/libraries/botbuilder-ai/requirements.txt +++ b/libraries/botbuilder-ai/requirements.txt @@ -1,9 +1,6 @@ msrest>=0.6.6 -#botframework-connector>=4.0.0.a6 botbuilder-schema>=4.0.0.a6 botbuilder-core>=4.0.0.a6 requests>=2.18.1 -#PyJWT==1.5.3 -#cryptography==2.1.4 -#aiounittest>=1.1.0 -azure-cognitiveservices-language-luis==0.1.0 \ No newline at end of file +aiounittest>=1.1.0 +azure-cognitiveservices-language-luis==0.2.0 \ No newline at end of file diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py index b76837fc0..1b4848f7d 100644 --- a/libraries/botbuilder-ai/setup.py +++ b/libraries/botbuilder-ai/setup.py @@ -6,9 +6,8 @@ REQUIRES = [ "aiounittest>=1.1.0", - "azure-cognitiveservices-language-luis==0.1.0", + "azure-cognitiveservices-language-luis==0.2.0", "botbuilder-schema>=4.0.0.a6", - #"botframework-connector>=4.0.0.a6", "botbuilder-core>=4.0.0.a6", ] diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index 51fc427c7..81ef24abf 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -1,5 +1,6 @@ import json import unittest +from os import path from unittest.mock import Mock, patch import requests @@ -128,10 +129,54 @@ def test_single_intent_simply_entity(self): self.assertEqual(15, result.entities["$instance"]["Name"][0]["endIndex"]) self.assert_score(result.entities["$instance"]["Name"][0]["score"]) + def test_null_utterance(self): + utterance: str = None + response_path: str = "SingleIntent_SimplyEntity.json" # The path is irrelevant in this case + + result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + + self.assertIsNotNone(result) + self.assertIsNone(result.altered_text) + self.assertEqual(utterance, result.text) + self.assertIsNotNone(result.intents) + self.assertEqual(1, len(result.intents)) + self.assertIsNotNone(result.intents[""]) + self.assertEqual(result.get_top_scoring_intent(), ("", 1.0)) + self.assertIsNotNone(result.entities) + self.assertEqual(0, len(result.entities)) + def assert_score(self, score: float): self.assertTrue(score >= 0) self.assertTrue(score <= 1) + @classmethod + def _get_recognizer_result(cls, utterance: str, response_file: str): + 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") as f: + response_str = f.read() + response_json = json.loads(response_str) + + my_app = LuisApplication( + LuisRecognizerTest._luisAppId, + LuisRecognizerTest._subscriptionKey, + endpoint="", + ) + recognizer = LuisRecognizer(my_app, prediction_options=None) + context = LuisRecognizerTest._get_context(utterance) + response = Mock(spec=Response) + response.status_code = 200 + response.headers = {} + response.reason = "" + with patch("requests.Session.send", return_value=response): + with patch( + "msrest.serialization.Deserializer._unpack_content", + return_value=response_json, + ): + result = recognizer.recognize(context) + return result + @classmethod def _get_luis_recognizer( cls, verbose: bool = False, options: LuisPredictionOptions = None diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Composite1.json b/libraries/botbuilder-ai/tests/luis/test_data/Composite1.json new file mode 100644 index 000000000..4e60af728 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Composite1.json @@ -0,0 +1,994 @@ +{ + "text": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5:30am and 4 acres and 4 pico meters and chrimc@hotmail.com and $4 and $4.25 and also 32 and 210.4 and first and 10% and 10.5% and 425-555-1234 and 3 degrees and -27.5 degrees c", + "intents": { + "EntityTests": { + "score": 0.9783022 + }, + "search": { + "score": 0.253596246 + }, + "Weather_GetForecast": { + "score": 0.0438077338 + }, + "None": { + "score": 0.0412048623 + }, + "Travel": { + "score": 0.0118790194 + }, + "Delivery": { + "score": 0.00688600726 + }, + "SpecifyName": { + "score": 0.00150657748 + }, + "Help": { + "score": 0.000121566052 + }, + "Cancel": { + "score": 5.180011E-05 + }, + "Greeting": { + "score": 1.6850714E-05 + } + }, + "entities": { + "$instance": { + "Composite1": [ + { + "startIndex": 0, + "endIndex": 262, + "text": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5 : 30am and 4 acres and 4 pico meters and chrimc @ hotmail . com and $ 4 and $ 4 . 25 and also 32 and 210 . 4 and first and 10 % and 10 . 5 % and 425 - 555 - 1234 and 3 degrees and - 27 . 5 degrees c", + "type": "Composite1", + "score": 0.7279488 + } + ] + }, + "Composite1": [ + { + "$instance": { + "age": [ + { + "startIndex": 0, + "endIndex": 12, + "text": "12 years old", + "type": "builtin.age" + }, + { + "startIndex": 17, + "endIndex": 27, + "text": "3 days old", + "type": "builtin.age" + } + ], + "datetime": [ + { + "startIndex": 0, + "endIndex": 8, + "text": "12 years", + "type": "builtin.datetimeV2.duration" + }, + { + "startIndex": 17, + "endIndex": 23, + "text": "3 days", + "type": "builtin.datetimeV2.duration" + }, + { + "startIndex": 32, + "endIndex": 47, + "text": "monday july 3rd", + "type": "builtin.datetimeV2.date" + }, + { + "startIndex": 52, + "endIndex": 64, + "text": "every monday", + "type": "builtin.datetimeV2.set" + }, + { + "startIndex": 69, + "endIndex": 91, + "text": "between 3am and 5:30am", + "type": "builtin.datetimeV2.timerange" + } + ], + "dimension": [ + { + "startIndex": 96, + "endIndex": 103, + "text": "4 acres", + "type": "builtin.dimension" + }, + { + "startIndex": 108, + "endIndex": 121, + "text": "4 pico meters", + "type": "builtin.dimension" + } + ], + "email": [ + { + "startIndex": 126, + "endIndex": 144, + "text": "chrimc@hotmail.com", + "type": "builtin.email" + } + ], + "money": [ + { + "startIndex": 149, + "endIndex": 151, + "text": "$4", + "type": "builtin.currency" + }, + { + "startIndex": 156, + "endIndex": 161, + "text": "$4.25", + "type": "builtin.currency" + } + ], + "number": [ + { + "startIndex": 0, + "endIndex": 2, + "text": "12", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 17, + "endIndex": 18, + "text": "3", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 85, + "endIndex": 86, + "text": "5", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 96, + "endIndex": 97, + "text": "4", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 108, + "endIndex": 109, + "text": "4", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 150, + "endIndex": 151, + "text": "4", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 157, + "endIndex": 161, + "text": "4.25", + "type": "builtin.number", + "subtype": "decimal" + }, + { + "startIndex": 171, + "endIndex": 173, + "text": "32", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 178, + "endIndex": 183, + "text": "210.4", + "type": "builtin.number", + "subtype": "decimal" + }, + { + "startIndex": 198, + "endIndex": 200, + "text": "10", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 206, + "endIndex": 210, + "text": "10.5", + "type": "builtin.number", + "subtype": "decimal" + }, + { + "startIndex": 216, + "endIndex": 219, + "text": "425", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 220, + "endIndex": 223, + "text": "555", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 224, + "endIndex": 228, + "text": "1234", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 233, + "endIndex": 234, + "text": "3", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 247, + "endIndex": 252, + "text": "-27.5", + "type": "builtin.number", + "subtype": "decimal" + } + ], + "ordinal": [ + { + "startIndex": 44, + "endIndex": 47, + "text": "3rd", + "type": "builtin.ordinal" + }, + { + "startIndex": 188, + "endIndex": 193, + "text": "first", + "type": "builtin.ordinal" + } + ], + "percentage": [ + { + "startIndex": 198, + "endIndex": 201, + "text": "10%", + "type": "builtin.percentage" + }, + { + "startIndex": 206, + "endIndex": 211, + "text": "10.5%", + "type": "builtin.percentage" + } + ], + "phonenumber": [ + { + "startIndex": 216, + "endIndex": 228, + "text": "425-555-1234", + "type": "builtin.phonenumber" + } + ], + "temperature": [ + { + "startIndex": 233, + "endIndex": 242, + "text": "3 degrees", + "type": "builtin.temperature" + }, + { + "startIndex": 247, + "endIndex": 262, + "text": "-27.5 degrees c", + "type": "builtin.temperature" + } + ] + }, + "age": [ + { + "number": 12, + "units": "Year" + }, + { + "number": 3, + "units": "Day" + } + ], + "datetime": [ + { + "type": "duration", + "timex": [ + "P12Y" + ] + }, + { + "type": "duration", + "timex": [ + "P3D" + ] + }, + { + "type": "date", + "timex": [ + "XXXX-07-03" + ] + }, + { + "type": "set", + "timex": [ + "XXXX-WXX-1" + ] + }, + { + "type": "timerange", + "timex": [ + "(T03,T05:30,PT2H30M)" + ] + } + ], + "dimension": [ + { + "number": 4, + "units": "Acre" + }, + { + "number": 4, + "units": "Picometer" + } + ], + "email": [ + "chrimc@hotmail.com" + ], + "money": [ + { + "number": 4, + "units": "Dollar" + }, + { + "number": 4.25, + "units": "Dollar" + } + ], + "number": [ + 12, + 3, + 5, + 4, + 4, + 4, + 4.25, + 32, + 210.4, + 10, + 10.5, + 425, + 555, + 1234, + 3, + -27.5 + ], + "ordinal": [ + 3, + 1 + ], + "percentage": [ + 10, + 10.5 + ], + "phonenumber": [ + "425-555-1234" + ], + "temperature": [ + { + "number": 3, + "units": "Degree" + }, + { + "number": -27.5, + "units": "C" + } + ] + } + ] + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "luisResult": { + "query": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5:30am and 4 acres and 4 pico meters and chrimc@hotmail.com and $4 and $4.25 and also 32 and 210.4 and first and 10% and 10.5% and 425-555-1234 and 3 degrees and -27.5 degrees c", + "topScoringIntent": { + "intent": "EntityTests", + "score": 0.9783022 + }, + "intents": [ + { + "intent": "EntityTests", + "score": 0.9783022 + }, + { + "intent": "search", + "score": 0.253596246 + }, + { + "intent": "Weather.GetForecast", + "score": 0.0438077338 + }, + { + "intent": "None", + "score": 0.0412048623 + }, + { + "intent": "Travel", + "score": 0.0118790194 + }, + { + "intent": "Delivery", + "score": 0.00688600726 + }, + { + "intent": "SpecifyName", + "score": 0.00150657748 + }, + { + "intent": "Help", + "score": 0.000121566052 + }, + { + "intent": "Cancel", + "score": 5.180011E-05 + }, + { + "intent": "Greeting", + "score": 1.6850714E-05 + } + ], + "entities": [ + { + "entity": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5 : 30am and 4 acres and 4 pico meters and chrimc @ hotmail . com and $ 4 and $ 4 . 25 and also 32 and 210 . 4 and first and 10 % and 10 . 5 % and 425 - 555 - 1234 and 3 degrees and - 27 . 5 degrees c", + "type": "Composite1", + "startIndex": 0, + "endIndex": 261, + "score": 0.7279488 + }, + { + "entity": "12 years old", + "type": "builtin.age", + "startIndex": 0, + "endIndex": 11, + "resolution": { + "unit": "Year", + "value": "12" + } + }, + { + "entity": "3 days old", + "type": "builtin.age", + "startIndex": 17, + "endIndex": 26, + "resolution": { + "unit": "Day", + "value": "3" + } + }, + { + "entity": "12 years", + "type": "builtin.datetimeV2.duration", + "startIndex": 0, + "endIndex": 7, + "resolution": { + "values": [ + { + "timex": "P12Y", + "type": "duration", + "value": "378432000" + } + ] + } + }, + { + "entity": "3 days", + "type": "builtin.datetimeV2.duration", + "startIndex": 17, + "endIndex": 22, + "resolution": { + "values": [ + { + "timex": "P3D", + "type": "duration", + "value": "259200" + } + ] + } + }, + { + "entity": "monday july 3rd", + "type": "builtin.datetimeV2.date", + "startIndex": 32, + "endIndex": 46, + "resolution": { + "values": [ + { + "timex": "XXXX-07-03", + "type": "date", + "value": "2018-07-03" + }, + { + "timex": "XXXX-07-03", + "type": "date", + "value": "2019-07-03" + } + ] + } + }, + { + "entity": "every monday", + "type": "builtin.datetimeV2.set", + "startIndex": 52, + "endIndex": 63, + "resolution": { + "values": [ + { + "timex": "XXXX-WXX-1", + "type": "set", + "value": "not resolved" + } + ] + } + }, + { + "entity": "between 3am and 5:30am", + "type": "builtin.datetimeV2.timerange", + "startIndex": 69, + "endIndex": 90, + "resolution": { + "values": [ + { + "timex": "(T03,T05:30,PT2H30M)", + "type": "timerange", + "start": "03:00:00", + "end": "05:30:00" + } + ] + } + }, + { + "entity": "4 acres", + "type": "builtin.dimension", + "startIndex": 96, + "endIndex": 102, + "resolution": { + "unit": "Acre", + "value": "4" + } + }, + { + "entity": "4 pico meters", + "type": "builtin.dimension", + "startIndex": 108, + "endIndex": 120, + "resolution": { + "unit": "Picometer", + "value": "4" + } + }, + { + "entity": "chrimc@hotmail.com", + "type": "builtin.email", + "startIndex": 126, + "endIndex": 143, + "resolution": { + "value": "chrimc@hotmail.com" + } + }, + { + "entity": "$4", + "type": "builtin.currency", + "startIndex": 149, + "endIndex": 150, + "resolution": { + "unit": "Dollar", + "value": "4" + } + }, + { + "entity": "$4.25", + "type": "builtin.currency", + "startIndex": 156, + "endIndex": 160, + "resolution": { + "unit": "Dollar", + "value": "4.25" + } + }, + { + "entity": "12", + "type": "builtin.number", + "startIndex": 0, + "endIndex": 1, + "resolution": { + "subtype": "integer", + "value": "12" + } + }, + { + "entity": "3", + "type": "builtin.number", + "startIndex": 17, + "endIndex": 17, + "resolution": { + "subtype": "integer", + "value": "3" + } + }, + { + "entity": "5", + "type": "builtin.number", + "startIndex": 85, + "endIndex": 85, + "resolution": { + "subtype": "integer", + "value": "5" + } + }, + { + "entity": "4", + "type": "builtin.number", + "startIndex": 96, + "endIndex": 96, + "resolution": { + "subtype": "integer", + "value": "4" + } + }, + { + "entity": "4", + "type": "builtin.number", + "startIndex": 108, + "endIndex": 108, + "resolution": { + "subtype": "integer", + "value": "4" + } + }, + { + "entity": "4", + "type": "builtin.number", + "startIndex": 150, + "endIndex": 150, + "resolution": { + "subtype": "integer", + "value": "4" + } + }, + { + "entity": "4.25", + "type": "builtin.number", + "startIndex": 157, + "endIndex": 160, + "resolution": { + "subtype": "decimal", + "value": "4.25" + } + }, + { + "entity": "32", + "type": "builtin.number", + "startIndex": 171, + "endIndex": 172, + "resolution": { + "subtype": "integer", + "value": "32" + } + }, + { + "entity": "210.4", + "type": "builtin.number", + "startIndex": 178, + "endIndex": 182, + "resolution": { + "subtype": "decimal", + "value": "210.4" + } + }, + { + "entity": "10", + "type": "builtin.number", + "startIndex": 198, + "endIndex": 199, + "resolution": { + "subtype": "integer", + "value": "10" + } + }, + { + "entity": "10.5", + "type": "builtin.number", + "startIndex": 206, + "endIndex": 209, + "resolution": { + "subtype": "decimal", + "value": "10.5" + } + }, + { + "entity": "425", + "type": "builtin.number", + "startIndex": 216, + "endIndex": 218, + "resolution": { + "subtype": "integer", + "value": "425" + } + }, + { + "entity": "555", + "type": "builtin.number", + "startIndex": 220, + "endIndex": 222, + "resolution": { + "subtype": "integer", + "value": "555" + } + }, + { + "entity": "1234", + "type": "builtin.number", + "startIndex": 224, + "endIndex": 227, + "resolution": { + "subtype": "integer", + "value": "1234" + } + }, + { + "entity": "3", + "type": "builtin.number", + "startIndex": 233, + "endIndex": 233, + "resolution": { + "subtype": "integer", + "value": "3" + } + }, + { + "entity": "-27.5", + "type": "builtin.number", + "startIndex": 247, + "endIndex": 251, + "resolution": { + "subtype": "decimal", + "value": "-27.5" + } + }, + { + "entity": "3rd", + "type": "builtin.ordinal", + "startIndex": 44, + "endIndex": 46, + "resolution": { + "value": "3" + } + }, + { + "entity": "first", + "type": "builtin.ordinal", + "startIndex": 188, + "endIndex": 192, + "resolution": { + "value": "1" + } + }, + { + "entity": "10%", + "type": "builtin.percentage", + "startIndex": 198, + "endIndex": 200, + "resolution": { + "value": "10%" + } + }, + { + "entity": "10.5%", + "type": "builtin.percentage", + "startIndex": 206, + "endIndex": 210, + "resolution": { + "value": "10.5%" + } + }, + { + "entity": "425-555-1234", + "type": "builtin.phonenumber", + "startIndex": 216, + "endIndex": 227, + "resolution": { + "score": "0.9", + "value": "425-555-1234" + } + }, + { + "entity": "3 degrees", + "type": "builtin.temperature", + "startIndex": 233, + "endIndex": 241, + "resolution": { + "unit": "Degree", + "value": "3" + } + }, + { + "entity": "-27.5 degrees c", + "type": "builtin.temperature", + "startIndex": 247, + "endIndex": 261, + "resolution": { + "unit": "C", + "value": "-27.5" + } + } + ], + "compositeEntities": [ + { + "parentType": "Composite1", + "value": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5 : 30am and 4 acres and 4 pico meters and chrimc @ hotmail . com and $ 4 and $ 4 . 25 and also 32 and 210 . 4 and first and 10 % and 10 . 5 % and 425 - 555 - 1234 and 3 degrees and - 27 . 5 degrees c", + "children": [ + { + "type": "builtin.age", + "value": "12 years old" + }, + { + "type": "builtin.age", + "value": "3 days old" + }, + { + "type": "builtin.datetimeV2.duration", + "value": "12 years" + }, + { + "type": "builtin.datetimeV2.duration", + "value": "3 days" + }, + { + "type": "builtin.datetimeV2.date", + "value": "monday july 3rd" + }, + { + "type": "builtin.datetimeV2.set", + "value": "every monday" + }, + { + "type": "builtin.datetimeV2.timerange", + "value": "between 3am and 5:30am" + }, + { + "type": "builtin.dimension", + "value": "4 acres" + }, + { + "type": "builtin.dimension", + "value": "4 pico meters" + }, + { + "type": "builtin.email", + "value": "chrimc@hotmail.com" + }, + { + "type": "builtin.currency", + "value": "$4" + }, + { + "type": "builtin.currency", + "value": "$4.25" + }, + { + "type": "builtin.number", + "value": "12" + }, + { + "type": "builtin.number", + "value": "3" + }, + { + "type": "builtin.number", + "value": "5" + }, + { + "type": "builtin.number", + "value": "4" + }, + { + "type": "builtin.number", + "value": "4" + }, + { + "type": "builtin.number", + "value": "4" + }, + { + "type": "builtin.number", + "value": "4.25" + }, + { + "type": "builtin.number", + "value": "32" + }, + { + "type": "builtin.number", + "value": "210.4" + }, + { + "type": "builtin.number", + "value": "10" + }, + { + "type": "builtin.number", + "value": "10.5" + }, + { + "type": "builtin.number", + "value": "425" + }, + { + "type": "builtin.number", + "value": "555" + }, + { + "type": "builtin.number", + "value": "1234" + }, + { + "type": "builtin.number", + "value": "3" + }, + { + "type": "builtin.number", + "value": "-27.5" + }, + { + "type": "builtin.ordinal", + "value": "3rd" + }, + { + "type": "builtin.ordinal", + "value": "first" + }, + { + "type": "builtin.percentage", + "value": "10%" + }, + { + "type": "builtin.percentage", + "value": "10.5%" + }, + { + "type": "builtin.phonenumber", + "value": "425-555-1234" + }, + { + "type": "builtin.temperature", + "value": "3 degrees" + }, + { + "type": "builtin.temperature", + "value": "-27.5 degrees c" + } + ] + } + ], + "sentimentAnalysis": { + "label": "neutral", + "score": 0.5 + } + } +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Composite2.json b/libraries/botbuilder-ai/tests/luis/test_data/Composite2.json new file mode 100644 index 000000000..36381fd44 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Composite2.json @@ -0,0 +1,221 @@ +{ + "text": "http://foo.com is where you can fly from seattle to dallas via denver", + "intents": { + "EntityTests": { + "score": 0.915071368 + }, + "Weather_GetForecast": { + "score": 0.103456922 + }, + "Travel": { + "score": 0.0230268724 + }, + "search": { + "score": 0.0197850317 + }, + "None": { + "score": 0.01063211 + }, + "Delivery": { + "score": 0.004947166 + }, + "SpecifyName": { + "score": 0.00322066387 + }, + "Help": { + "score": 0.00182514545 + }, + "Cancel": { + "score": 0.0008727567 + }, + "Greeting": { + "score": 0.000494661159 + } + }, + "entities": { + "$instance": { + "Composite2": [ + { + "startIndex": 0, + "endIndex": 69, + "text": "http : / / foo . com is where you can fly from seattle to dallas via denver", + "type": "Composite2", + "score": 0.91574204 + } + ] + }, + "Composite2": [ + { + "$instance": { + "To": [ + { + "startIndex": 52, + "endIndex": 58, + "text": "dallas", + "type": "City::To", + "score": 0.9924016 + } + ], + "From": [ + { + "startIndex": 41, + "endIndex": 48, + "text": "seattle", + "type": "City::From", + "score": 0.995012 + } + ], + "City": [ + { + "startIndex": 63, + "endIndex": 69, + "text": "denver", + "type": "City", + "score": 0.8450125 + } + ], + "url": [ + { + "startIndex": 0, + "endIndex": 14, + "text": "http://foo.com", + "type": "builtin.url" + } + ] + }, + "To": [ + "dallas" + ], + "From": [ + "seattle" + ], + "City": [ + "denver" + ], + "url": [ + "http://foo.com" + ] + } + ] + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "luisResult": { + "query": "http://foo.com is where you can fly from seattle to dallas via denver", + "topScoringIntent": { + "intent": "EntityTests", + "score": 0.915071368 + }, + "intents": [ + { + "intent": "EntityTests", + "score": 0.915071368 + }, + { + "intent": "Weather.GetForecast", + "score": 0.103456922 + }, + { + "intent": "Travel", + "score": 0.0230268724 + }, + { + "intent": "search", + "score": 0.0197850317 + }, + { + "intent": "None", + "score": 0.01063211 + }, + { + "intent": "Delivery", + "score": 0.004947166 + }, + { + "intent": "SpecifyName", + "score": 0.00322066387 + }, + { + "intent": "Help", + "score": 0.00182514545 + }, + { + "intent": "Cancel", + "score": 0.0008727567 + }, + { + "intent": "Greeting", + "score": 0.000494661159 + } + ], + "entities": [ + { + "entity": "dallas", + "type": "City::To", + "startIndex": 52, + "endIndex": 57, + "score": 0.9924016 + }, + { + "entity": "seattle", + "type": "City::From", + "startIndex": 41, + "endIndex": 47, + "score": 0.995012 + }, + { + "entity": "denver", + "type": "City", + "startIndex": 63, + "endIndex": 68, + "score": 0.8450125 + }, + { + "entity": "http : / / foo . com is where you can fly from seattle to dallas via denver", + "type": "Composite2", + "startIndex": 0, + "endIndex": 68, + "score": 0.91574204 + }, + { + "entity": "http://foo.com", + "type": "builtin.url", + "startIndex": 0, + "endIndex": 13, + "resolution": { + "value": "http://foo.com" + } + } + ], + "compositeEntities": [ + { + "parentType": "Composite2", + "value": "http : / / foo . com is where you can fly from seattle to dallas via denver", + "children": [ + { + "type": "City::To", + "value": "dallas" + }, + { + "type": "City::From", + "value": "seattle" + }, + { + "type": "City", + "value": "denver" + }, + { + "type": "builtin.url", + "value": "http://foo.com" + } + ] + } + ], + "sentimentAnalysis": { + "label": "neutral", + "score": 0.5 + } + } +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Composite3.json b/libraries/botbuilder-ai/tests/luis/test_data/Composite3.json new file mode 100644 index 000000000..ff5289410 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Composite3.json @@ -0,0 +1,256 @@ +{ + "text": "Deliver from 12345 VA to 12346 WA", + "intents": { + "Delivery": { + "score": 0.999999642 + }, + "search": { + "score": 5.50502E-06 + }, + "None": { + "score": 1.97937743E-06 + }, + "EntityTests": { + "score": 1.76767367E-06 + }, + "Travel": { + "score": 1.76767367E-06 + }, + "Weather_GetForecast": { + "score": 5.997471E-07 + }, + "SpecifyName": { + "score": 1.75E-09 + }, + "Greeting": { + "score": 5.9375E-10 + }, + "Cancel": { + "score": 5.529412E-10 + }, + "Help": { + "score": 5.529412E-10 + } + }, + "entities": { + "$instance": { + "Source": [ + { + "startIndex": 13, + "endIndex": 21, + "text": "12345 va", + "type": "Address", + "score": 0.7669167 + } + ], + "Destination": [ + { + "startIndex": 25, + "endIndex": 33, + "text": "12346 wa", + "type": "Address", + "score": 0.9737196 + } + ] + }, + "Source": [ + { + "$instance": { + "State": [ + { + "startIndex": 19, + "endIndex": 21, + "text": "va", + "type": "State", + "score": 0.8453893 + } + ], + "number": [ + { + "startIndex": 13, + "endIndex": 18, + "text": "12345", + "type": "builtin.number", + "subtype": "integer" + } + ] + }, + "State": [ + "va" + ], + "number": [ + 12345 + ] + } + ], + "Destination": [ + { + "$instance": { + "State": [ + { + "startIndex": 31, + "endIndex": 33, + "text": "wa", + "type": "State", + "score": 0.9857455 + } + ], + "number": [ + { + "startIndex": 25, + "endIndex": 30, + "text": "12346", + "type": "builtin.number", + "subtype": "integer" + } + ] + }, + "State": [ + "wa" + ], + "number": [ + 12346 + ] + } + ] + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "luisResult": { + "query": "Deliver from 12345 VA to 12346 WA", + "topScoringIntent": { + "intent": "Delivery", + "score": 0.999999642 + }, + "intents": [ + { + "intent": "Delivery", + "score": 0.999999642 + }, + { + "intent": "search", + "score": 5.50502E-06 + }, + { + "intent": "None", + "score": 1.97937743E-06 + }, + { + "intent": "EntityTests", + "score": 1.76767367E-06 + }, + { + "intent": "Travel", + "score": 1.76767367E-06 + }, + { + "intent": "Weather.GetForecast", + "score": 5.997471E-07 + }, + { + "intent": "SpecifyName", + "score": 1.75E-09 + }, + { + "intent": "Greeting", + "score": 5.9375E-10 + }, + { + "intent": "Cancel", + "score": 5.529412E-10 + }, + { + "intent": "Help", + "score": 5.529412E-10 + } + ], + "entities": [ + { + "entity": "va", + "type": "State", + "startIndex": 19, + "endIndex": 20, + "score": 0.8453893 + }, + { + "entity": "wa", + "type": "State", + "startIndex": 31, + "endIndex": 32, + "score": 0.9857455 + }, + { + "entity": "12345 va", + "type": "Address", + "startIndex": 13, + "endIndex": 20, + "score": 0.7669167, + "role": "Source" + }, + { + "entity": "12346 wa", + "type": "Address", + "startIndex": 25, + "endIndex": 32, + "score": 0.9737196, + "role": "Destination" + }, + { + "entity": "12345", + "type": "builtin.number", + "startIndex": 13, + "endIndex": 17, + "resolution": { + "subtype": "integer", + "value": "12345" + } + }, + { + "entity": "12346", + "type": "builtin.number", + "startIndex": 25, + "endIndex": 29, + "resolution": { + "subtype": "integer", + "value": "12346" + } + } + ], + "compositeEntities": [ + { + "parentType": "Address", + "value": "12345 va", + "children": [ + { + "type": "State", + "value": "va" + }, + { + "type": "builtin.number", + "value": "12345" + } + ] + }, + { + "parentType": "Address", + "value": "12346 wa", + "children": [ + { + "type": "State", + "value": "wa" + }, + { + "type": "builtin.number", + "value": "12346" + } + ] + } + ], + "sentimentAnalysis": { + "label": "neutral", + "score": 0.5 + } + } +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/MultipleDateTimeEntities.json b/libraries/botbuilder-ai/tests/luis/test_data/MultipleDateTimeEntities.json new file mode 100644 index 000000000..820d8b8ee --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/MultipleDateTimeEntities.json @@ -0,0 +1,93 @@ +{ + "query": "Book a table on Friday or tomorrow at 5 or tomorrow at 4", + "topScoringIntent": { + "intent": "None", + "score": 0.8785189 + }, + "intents": [ + { + "intent": "None", + "score": 0.8785189 + } + ], + "entities": [ + { + "entity": "friday", + "type": "builtin.datetimeV2.date", + "startIndex": 16, + "endIndex": 21, + "resolution": { + "values": [ + { + "timex": "XXXX-WXX-5", + "type": "date", + "value": "2018-07-13" + }, + { + "timex": "XXXX-WXX-5", + "type": "date", + "value": "2018-07-20" + } + ] + } + }, + { + "entity": "tomorrow at 5", + "type": "builtin.datetimeV2.datetime", + "startIndex": 26, + "endIndex": 38, + "resolution": { + "values": [ + { + "timex": "2018-07-19T05", + "type": "datetime", + "value": "2018-07-19 05:00:00" + }, + { + "timex": "2018-07-19T17", + "type": "datetime", + "value": "2018-07-19 17:00:00" + } + ] + } + }, + { + "entity": "tomorrow at 4", + "type": "builtin.datetimeV2.datetime", + "startIndex": 43, + "endIndex": 55, + "resolution": { + "values": [ + { + "timex": "2018-07-19T04", + "type": "datetime", + "value": "2018-07-19 04:00:00" + }, + { + "timex": "2018-07-19T16", + "type": "datetime", + "value": "2018-07-19 16:00:00" + } + ] + } + }, + { + "entity": "5", + "type": "builtin.number", + "startIndex": 38, + "endIndex": 38, + "resolution": { + "value": "5" + } + }, + { + "entity": "4", + "type": "builtin.number", + "startIndex": 55, + "endIndex": 55, + "resolution": { + "value": "4" + } + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_CompositeEntityModel.json b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_CompositeEntityModel.json new file mode 100644 index 000000000..3b7c53ac3 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_CompositeEntityModel.json @@ -0,0 +1,54 @@ +{ + "query": "Please deliver it to 98033 WA", + "topScoringIntent": { + "intent": "Delivery", + "score": 0.8785189 + }, + "intents": [ + { + "intent": "Delivery", + "score": 0.8785189 + } + ], + "entities": [ + { + "entity": "98033 wa", + "type": "Address", + "startIndex": 21, + "endIndex": 28, + "score": 0.864295959 + }, + { + "entity": "98033", + "type": "builtin.number", + "startIndex": 21, + "endIndex": 25, + "resolution": { + "value": "98033" + } + }, + { + "entity": "wa", + "type": "State", + "startIndex": 27, + "endIndex": 28, + "score": 0.8981885 + } + ], + "compositeEntities": [ + { + "parentType": "Address", + "value": "98033 wa", + "children": [ + { + "type": "builtin.number", + "value": "98033" + }, + { + "type": "State", + "value": "wa" + } + ] + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_ListEntityWithMultiValues.json b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_ListEntityWithMultiValues.json new file mode 100644 index 000000000..37403eaf9 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_ListEntityWithMultiValues.json @@ -0,0 +1,27 @@ +{ + "query": "I want to travel on DL", + "topScoringIntent": { + "intent": "Travel", + "score": 0.8785189 + }, + "intents": [ + { + "intent": "Travel", + "score": 0.8785189 + } + ], + "entities": [ + { + "entity": "dl", + "type": "Airline", + "startIndex": 20, + "endIndex": 21, + "resolution": { + "values": [ + "Virgin", + "Delta" + ] + } + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_ListEntityWithSingleValue.json b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_ListEntityWithSingleValue.json new file mode 100644 index 000000000..152aa522f --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_ListEntityWithSingleValue.json @@ -0,0 +1,26 @@ +{ + "query": "I want to travel on united", + "topScoringIntent": { + "intent": "Travel", + "score": 0.8785189 + }, + "intents": [ + { + "intent": "Travel", + "score": 0.8785189 + } + ], + "entities": [ + { + "entity": "united", + "type": "Airline", + "startIndex": 20, + "endIndex": 25, + "resolution": { + "values": [ + "United" + ] + } + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_PrebuiltEntitiesWithMultiValues.json b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_PrebuiltEntitiesWithMultiValues.json new file mode 100644 index 000000000..05895adb5 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_PrebuiltEntitiesWithMultiValues.json @@ -0,0 +1,52 @@ +{ + "query": "Please deliver February 2nd 2001 in room 201", + "topScoringIntent": { + "intent": "Delivery", + "score": 0.8785189 + }, + "intents": [ + { + "intent": "Delivery", + "score": 0.8785189 + }, + { + "intent": "SpecifyName", + "score": 0.0085189 + } + ], + "entities": [ + { + "entity": 2001, + "type": "number", + "startIndex": 28, + "endIndex": 31 + }, + { + "entity": 201, + "type": "number", + "startIndex": 41, + "endIndex": 43 + }, + { + "entity": 2, + "type": "ordinal", + "startIndex": 24, + "endIndex": 26 + }, + { + "entity": "february 2nd 2001", + "type": "builtin.datetimeV2.date", + "startIndex": 15, + "endIndex": 31, + "resolution": { + "values": [ + { + "timex": "2001-02-02", + "type": "date", + "value": "2001-02-02" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_PrebuiltEntity.json b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_PrebuiltEntity.json new file mode 100644 index 000000000..69f6809a0 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/MultipleIntents_PrebuiltEntity.json @@ -0,0 +1,46 @@ +{ + "query": "Please deliver February 2nd 2001", + "topScoringIntent": { + "intent": "Delivery", + "score": 0.8785189 + }, + "intents": [ + { + "intent": "Delivery", + "score": 0.8785189 + }, + { + "intent": "SpecifyName", + "score": 0.0085189 + } + ], + "entities": [ + { + "entity": 2001, + "type": "number", + "startIndex": 28, + "endIndex": 31 + }, + { + "entity": 2, + "type": "ordinal", + "startIndex": 24, + "endIndex": 26 + }, + { + "entity": "february 2nd 2001", + "type": "builtin.datetimeV2.date", + "startIndex": 15, + "endIndex": 31, + "resolution": { + "values": [ + { + "timex": "2001-02-02", + "type": "date", + "value": "2001-02-02" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Patterns.json b/libraries/botbuilder-ai/tests/luis/test_data/Patterns.json new file mode 100644 index 000000000..15e935e2f --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Patterns.json @@ -0,0 +1,169 @@ +{ + "text": "email about something wicked this way comes from bart simpson and also kb435", + "intents": { + "search": { + "score": 0.999999 + }, + "None": { + "score": 7.91005E-06 + }, + "EntityTests": { + "score": 5.412342E-06 + }, + "Weather_GetForecast": { + "score": 3.7898792E-06 + }, + "Delivery": { + "score": 2.06122013E-06 + }, + "SpecifyName": { + "score": 1.76767367E-06 + }, + "Travel": { + "score": 1.76767367E-06 + }, + "Greeting": { + "score": 5.9375E-10 + }, + "Cancel": { + "score": 5.529412E-10 + }, + "Help": { + "score": 5.529412E-10 + } + }, + "entities": { + "$instance": { + "Part": [ + { + "startIndex": 71, + "endIndex": 76, + "text": "kb435", + "type": "Part" + } + ], + "subject": [ + { + "startIndex": 12, + "endIndex": 43, + "text": "something wicked this way comes", + "type": "subject" + } + ], + "person": [ + { + "startIndex": 49, + "endIndex": 61, + "text": "bart simpson", + "type": "person" + } + ], + "extra": [ + { + "startIndex": 71, + "endIndex": 76, + "text": "kb435", + "type": "subject" + } + ] + }, + "Part": [ + "kb435" + ], + "subject": [ + "something wicked this way comes" + ], + "person": [ + "bart simpson" + ], + "extra": [ + "kb435" + ] + }, + "sentiment": { + "label": "negative", + "score": 0.210341513 + }, + "luisResult": { + "query": "email about something wicked this way comes from bart simpson and also kb435", + "topScoringIntent": { + "intent": "search", + "score": 0.999999 + }, + "intents": [ + { + "intent": "search", + "score": 0.999999 + }, + { + "intent": "None", + "score": 7.91005E-06 + }, + { + "intent": "EntityTests", + "score": 5.412342E-06 + }, + { + "intent": "Weather.GetForecast", + "score": 3.7898792E-06 + }, + { + "intent": "Delivery", + "score": 2.06122013E-06 + }, + { + "intent": "SpecifyName", + "score": 1.76767367E-06 + }, + { + "intent": "Travel", + "score": 1.76767367E-06 + }, + { + "intent": "Greeting", + "score": 5.9375E-10 + }, + { + "intent": "Cancel", + "score": 5.529412E-10 + }, + { + "intent": "Help", + "score": 5.529412E-10 + } + ], + "entities": [ + { + "entity": "kb435", + "type": "Part", + "startIndex": 71, + "endIndex": 75 + }, + { + "entity": "something wicked this way comes", + "type": "subject", + "startIndex": 12, + "endIndex": 42, + "role": "" + }, + { + "entity": "bart simpson", + "type": "person", + "startIndex": 49, + "endIndex": 60, + "role": "" + }, + { + "entity": "kb435", + "type": "subject", + "startIndex": 71, + "endIndex": 75, + "role": "extra" + } + ], + "sentimentAnalysis": { + "label": "negative", + "score": 0.210341513 + } + } +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Prebuilt.json b/libraries/botbuilder-ai/tests/luis/test_data/Prebuilt.json new file mode 100644 index 000000000..fc930521c --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Prebuilt.json @@ -0,0 +1,175 @@ +{ + "text": "http://foo.com is where you can get a weather forecast for seattle", + "intents": { + "Weather_GetForecast": { + "score": 0.8973387 + }, + "EntityTests": { + "score": 0.6120084 + }, + "None": { + "score": 0.038558647 + }, + "search": { + "score": 0.0183345526 + }, + "Travel": { + "score": 0.00512401946 + }, + "Delivery": { + "score": 0.00396467233 + }, + "SpecifyName": { + "score": 0.00337156886 + }, + "Help": { + "score": 0.00175959955 + }, + "Cancel": { + "score": 0.000602799933 + }, + "Greeting": { + "score": 0.000445256825 + } + }, + "entities": { + "$instance": { + "Composite2": [ + { + "startIndex": 0, + "endIndex": 66, + "text": "http : / / foo . com is where you can get a weather forecast for seattle", + "type": "Composite2", + "score": 0.572650731 + } + ] + }, + "Composite2": [ + { + "$instance": { + "Weather_Location": [ + { + "startIndex": 59, + "endIndex": 66, + "text": "seattle", + "type": "Weather.Location", + "score": 0.8812625 + } + ], + "url": [ + { + "startIndex": 0, + "endIndex": 14, + "text": "http://foo.com", + "type": "builtin.url" + } + ] + }, + "Weather_Location": [ + "seattle" + ], + "url": [ + "http://foo.com" + ] + } + ] + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "luisResult": { + "query": "http://foo.com is where you can get a weather forecast for seattle", + "topScoringIntent": { + "intent": "Weather.GetForecast", + "score": 0.8973387 + }, + "intents": [ + { + "intent": "Weather.GetForecast", + "score": 0.8973387 + }, + { + "intent": "EntityTests", + "score": 0.6120084 + }, + { + "intent": "None", + "score": 0.038558647 + }, + { + "intent": "search", + "score": 0.0183345526 + }, + { + "intent": "Travel", + "score": 0.00512401946 + }, + { + "intent": "Delivery", + "score": 0.00396467233 + }, + { + "intent": "SpecifyName", + "score": 0.00337156886 + }, + { + "intent": "Help", + "score": 0.00175959955 + }, + { + "intent": "Cancel", + "score": 0.000602799933 + }, + { + "intent": "Greeting", + "score": 0.000445256825 + } + ], + "entities": [ + { + "entity": "seattle", + "type": "Weather.Location", + "startIndex": 59, + "endIndex": 65, + "score": 0.8812625 + }, + { + "entity": "http : / / foo . com is where you can get a weather forecast for seattle", + "type": "Composite2", + "startIndex": 0, + "endIndex": 65, + "score": 0.572650731 + }, + { + "entity": "http://foo.com", + "type": "builtin.url", + "startIndex": 0, + "endIndex": 13, + "resolution": { + "value": "http://foo.com" + } + } + ], + "compositeEntities": [ + { + "parentType": "Composite2", + "value": "http : / / foo . com is where you can get a weather forecast for seattle", + "children": [ + { + "type": "Weather.Location", + "value": "seattle" + }, + { + "type": "builtin.url", + "value": "http://foo.com" + } + ] + } + ], + "sentimentAnalysis": { + "label": "neutral", + "score": 0.5 + } + } +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/SingleIntent_SimplyEntity.json b/libraries/botbuilder-ai/tests/luis/test_data/SingleIntent_SimplyEntity.json new file mode 100644 index 000000000..3b6af6864 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/SingleIntent_SimplyEntity.json @@ -0,0 +1,22 @@ +{ + "query": "my name is Emad", + "topScoringIntent": { + "intent": "SpecifyName", + "score": 0.8785189 + }, + "intents": [ + { + "intent": "SpecifyName", + "score": 0.8785189 + } + ], + "entities": [ + { + "entity": "emad", + "type": "Name", + "startIndex": 11, + "endIndex": 14, + "score": 0.8446753 + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/TraceActivity.json b/libraries/botbuilder-ai/tests/luis/test_data/TraceActivity.json new file mode 100644 index 000000000..54e74fb27 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/TraceActivity.json @@ -0,0 +1,22 @@ +{ + "query": "My name is Emad", + "topScoringIntent": { + "intent": "SpecifyName", + "score": 0.8785189 + }, + "intents": [ + { + "intent": "SpecifyName", + "score": 0.8785189 + } + ], + "entities": [ + { + "entity": "emad", + "type": "Name", + "startIndex": 11, + "endIndex": 14, + "score": 0.8446753 + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/Typed.json b/libraries/botbuilder-ai/tests/luis/test_data/Typed.json new file mode 100644 index 000000000..3645125b6 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/Typed.json @@ -0,0 +1,996 @@ +{ + "Text": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5:30am and 4 acres and 4 pico meters and chrimc@hotmail.com and $4 and $4.25 and also 32 and 210.4 and first and 10% and 10.5% and 425-555-1234 and 3 degrees and -27.5 degrees c", + "Intents": { + "EntityTests": { + "score": 0.9783022 + }, + "search": { + "score": 0.253596246 + }, + "Weather_GetForecast": { + "score": 0.0438077338 + }, + "None": { + "score": 0.0412048623 + }, + "Travel": { + "score": 0.0118790194 + }, + "Delivery": { + "score": 0.00688600726 + }, + "SpecifyName": { + "score": 0.00150657748 + }, + "Help": { + "score": 0.000121566052 + }, + "Cancel": { + "score": 5.180011E-05 + }, + "Greeting": { + "score": 1.6850714E-05 + } + }, + "Entities": { + "Composite1": [ + { + "age": [ + { + "number": 12.0, + "units": "Year" + }, + { + "number": 3.0, + "units": "Day" + } + ], + "datetime": [ + { + "type": "duration", + "timex": [ + "P12Y" + ] + }, + { + "type": "duration", + "timex": [ + "P3D" + ] + }, + { + "type": "date", + "timex": [ + "XXXX-07-03" + ] + }, + { + "type": "set", + "timex": [ + "XXXX-WXX-1" + ] + }, + { + "type": "timerange", + "timex": [ + "(T03,T05:30,PT2H30M)" + ] + } + ], + "dimension": [ + { + "number": 4.0, + "units": "Acre" + }, + { + "number": 4.0, + "units": "Picometer" + } + ], + "email": [ + "chrimc@hotmail.com" + ], + "money": [ + { + "number": 4.0, + "units": "Dollar" + }, + { + "number": 4.25, + "units": "Dollar" + } + ], + "number": [ + 12.0, + 3.0, + 5.0, + 4.0, + 4.0, + 4.0, + 4.25, + 32.0, + 210.4, + 10.0, + 10.5, + 425.0, + 555.0, + 1234.0, + 3.0, + -27.5 + ], + "ordinal": [ + 3.0, + 1.0 + ], + "percentage": [ + 10.0, + 10.5 + ], + "phonenumber": [ + "425-555-1234" + ], + "temperature": [ + { + "number": 3.0, + "units": "Degree" + }, + { + "number": -27.5, + "units": "C" + } + ], + "$instance": { + "age": [ + { + "startIndex": 0, + "endIndex": 12, + "text": "12 years old", + "type": "builtin.age" + }, + { + "startIndex": 17, + "endIndex": 27, + "text": "3 days old", + "type": "builtin.age" + } + ], + "datetime": [ + { + "startIndex": 0, + "endIndex": 8, + "text": "12 years", + "type": "builtin.datetimeV2.duration" + }, + { + "startIndex": 17, + "endIndex": 23, + "text": "3 days", + "type": "builtin.datetimeV2.duration" + }, + { + "startIndex": 32, + "endIndex": 47, + "text": "monday july 3rd", + "type": "builtin.datetimeV2.date" + }, + { + "startIndex": 52, + "endIndex": 64, + "text": "every monday", + "type": "builtin.datetimeV2.set" + }, + { + "startIndex": 69, + "endIndex": 91, + "text": "between 3am and 5:30am", + "type": "builtin.datetimeV2.timerange" + } + ], + "dimension": [ + { + "startIndex": 96, + "endIndex": 103, + "text": "4 acres", + "type": "builtin.dimension" + }, + { + "startIndex": 108, + "endIndex": 121, + "text": "4 pico meters", + "type": "builtin.dimension" + } + ], + "email": [ + { + "startIndex": 126, + "endIndex": 144, + "text": "chrimc@hotmail.com", + "type": "builtin.email" + } + ], + "money": [ + { + "startIndex": 149, + "endIndex": 151, + "text": "$4", + "type": "builtin.currency" + }, + { + "startIndex": 156, + "endIndex": 161, + "text": "$4.25", + "type": "builtin.currency" + } + ], + "number": [ + { + "startIndex": 0, + "endIndex": 2, + "text": "12", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 17, + "endIndex": 18, + "text": "3", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 85, + "endIndex": 86, + "text": "5", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 96, + "endIndex": 97, + "text": "4", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 108, + "endIndex": 109, + "text": "4", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 150, + "endIndex": 151, + "text": "4", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 157, + "endIndex": 161, + "text": "4.25", + "type": "builtin.number", + "subtype": "decimal" + }, + { + "startIndex": 171, + "endIndex": 173, + "text": "32", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 178, + "endIndex": 183, + "text": "210.4", + "type": "builtin.number", + "subtype": "decimal" + }, + { + "startIndex": 198, + "endIndex": 200, + "text": "10", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 206, + "endIndex": 210, + "text": "10.5", + "type": "builtin.number", + "subtype": "decimal" + }, + { + "startIndex": 216, + "endIndex": 219, + "text": "425", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 220, + "endIndex": 223, + "text": "555", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 224, + "endIndex": 228, + "text": "1234", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 233, + "endIndex": 234, + "text": "3", + "type": "builtin.number", + "subtype": "integer" + }, + { + "startIndex": 247, + "endIndex": 252, + "text": "-27.5", + "type": "builtin.number", + "subtype": "decimal" + } + ], + "ordinal": [ + { + "startIndex": 44, + "endIndex": 47, + "text": "3rd", + "type": "builtin.ordinal" + }, + { + "startIndex": 188, + "endIndex": 193, + "text": "first", + "type": "builtin.ordinal" + } + ], + "percentage": [ + { + "startIndex": 198, + "endIndex": 201, + "text": "10%", + "type": "builtin.percentage" + }, + { + "startIndex": 206, + "endIndex": 211, + "text": "10.5%", + "type": "builtin.percentage" + } + ], + "phonenumber": [ + { + "startIndex": 216, + "endIndex": 228, + "text": "425-555-1234", + "type": "builtin.phonenumber" + } + ], + "temperature": [ + { + "startIndex": 233, + "endIndex": 242, + "text": "3 degrees", + "type": "builtin.temperature" + }, + { + "startIndex": 247, + "endIndex": 262, + "text": "-27.5 degrees c", + "type": "builtin.temperature" + } + ] + } + } + ], + "$instance": { + "Composite1": [ + { + "startIndex": 0, + "endIndex": 262, + "text": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5 : 30am and 4 acres and 4 pico meters and chrimc @ hotmail . com and $ 4 and $ 4 . 25 and also 32 and 210 . 4 and first and 10 % and 10 . 5 % and 425 - 555 - 1234 and 3 degrees and - 27 . 5 degrees c", + "score": 0.7279488, + "type": "Composite1" + } + ] + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "luisResult": { + "query": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5:30am and 4 acres and 4 pico meters and chrimc@hotmail.com and $4 and $4.25 and also 32 and 210.4 and first and 10% and 10.5% and 425-555-1234 and 3 degrees and -27.5 degrees c", + "alteredQuery": null, + "topScoringIntent": { + "intent": "EntityTests", + "score": 0.9783022 + }, + "intents": [ + { + "intent": "EntityTests", + "score": 0.9783022 + }, + { + "intent": "search", + "score": 0.253596246 + }, + { + "intent": "Weather.GetForecast", + "score": 0.0438077338 + }, + { + "intent": "None", + "score": 0.0412048623 + }, + { + "intent": "Travel", + "score": 0.0118790194 + }, + { + "intent": "Delivery", + "score": 0.00688600726 + }, + { + "intent": "SpecifyName", + "score": 0.00150657748 + }, + { + "intent": "Help", + "score": 0.000121566052 + }, + { + "intent": "Cancel", + "score": 5.180011E-05 + }, + { + "intent": "Greeting", + "score": 1.6850714E-05 + } + ], + "entities": [ + { + "entity": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5 : 30am and 4 acres and 4 pico meters and chrimc @ hotmail . com and $ 4 and $ 4 . 25 and also 32 and 210 . 4 and first and 10 % and 10 . 5 % and 425 - 555 - 1234 and 3 degrees and - 27 . 5 degrees c", + "type": "Composite1", + "startIndex": 0, + "endIndex": 261, + "score": 0.7279488 + }, + { + "entity": "12 years old", + "type": "builtin.age", + "startIndex": 0, + "endIndex": 11, + "resolution": { + "unit": "Year", + "value": "12" + } + }, + { + "entity": "3 days old", + "type": "builtin.age", + "startIndex": 17, + "endIndex": 26, + "resolution": { + "unit": "Day", + "value": "3" + } + }, + { + "entity": "12 years", + "type": "builtin.datetimeV2.duration", + "startIndex": 0, + "endIndex": 7, + "resolution": { + "values": [ + { + "timex": "P12Y", + "type": "duration", + "value": "378432000" + } + ] + } + }, + { + "entity": "3 days", + "type": "builtin.datetimeV2.duration", + "startIndex": 17, + "endIndex": 22, + "resolution": { + "values": [ + { + "timex": "P3D", + "type": "duration", + "value": "259200" + } + ] + } + }, + { + "entity": "monday july 3rd", + "type": "builtin.datetimeV2.date", + "startIndex": 32, + "endIndex": 46, + "resolution": { + "values": [ + { + "timex": "XXXX-07-03", + "type": "date", + "value": "2018-07-03" + }, + { + "timex": "XXXX-07-03", + "type": "date", + "value": "2019-07-03" + } + ] + } + }, + { + "entity": "every monday", + "type": "builtin.datetimeV2.set", + "startIndex": 52, + "endIndex": 63, + "resolution": { + "values": [ + { + "timex": "XXXX-WXX-1", + "type": "set", + "value": "not resolved" + } + ] + } + }, + { + "entity": "between 3am and 5:30am", + "type": "builtin.datetimeV2.timerange", + "startIndex": 69, + "endIndex": 90, + "resolution": { + "values": [ + { + "timex": "(T03,T05:30,PT2H30M)", + "type": "timerange", + "start": "03:00:00", + "end": "05:30:00" + } + ] + } + }, + { + "entity": "4 acres", + "type": "builtin.dimension", + "startIndex": 96, + "endIndex": 102, + "resolution": { + "unit": "Acre", + "value": "4" + } + }, + { + "entity": "4 pico meters", + "type": "builtin.dimension", + "startIndex": 108, + "endIndex": 120, + "resolution": { + "unit": "Picometer", + "value": "4" + } + }, + { + "entity": "chrimc@hotmail.com", + "type": "builtin.email", + "startIndex": 126, + "endIndex": 143, + "resolution": { + "value": "chrimc@hotmail.com" + } + }, + { + "entity": "$4", + "type": "builtin.currency", + "startIndex": 149, + "endIndex": 150, + "resolution": { + "unit": "Dollar", + "value": "4" + } + }, + { + "entity": "$4.25", + "type": "builtin.currency", + "startIndex": 156, + "endIndex": 160, + "resolution": { + "unit": "Dollar", + "value": "4.25" + } + }, + { + "entity": "12", + "type": "builtin.number", + "startIndex": 0, + "endIndex": 1, + "resolution": { + "subtype": "integer", + "value": "12" + } + }, + { + "entity": "3", + "type": "builtin.number", + "startIndex": 17, + "endIndex": 17, + "resolution": { + "subtype": "integer", + "value": "3" + } + }, + { + "entity": "5", + "type": "builtin.number", + "startIndex": 85, + "endIndex": 85, + "resolution": { + "subtype": "integer", + "value": "5" + } + }, + { + "entity": "4", + "type": "builtin.number", + "startIndex": 96, + "endIndex": 96, + "resolution": { + "subtype": "integer", + "value": "4" + } + }, + { + "entity": "4", + "type": "builtin.number", + "startIndex": 108, + "endIndex": 108, + "resolution": { + "subtype": "integer", + "value": "4" + } + }, + { + "entity": "4", + "type": "builtin.number", + "startIndex": 150, + "endIndex": 150, + "resolution": { + "subtype": "integer", + "value": "4" + } + }, + { + "entity": "4.25", + "type": "builtin.number", + "startIndex": 157, + "endIndex": 160, + "resolution": { + "subtype": "decimal", + "value": "4.25" + } + }, + { + "entity": "32", + "type": "builtin.number", + "startIndex": 171, + "endIndex": 172, + "resolution": { + "subtype": "integer", + "value": "32" + } + }, + { + "entity": "210.4", + "type": "builtin.number", + "startIndex": 178, + "endIndex": 182, + "resolution": { + "subtype": "decimal", + "value": "210.4" + } + }, + { + "entity": "10", + "type": "builtin.number", + "startIndex": 198, + "endIndex": 199, + "resolution": { + "subtype": "integer", + "value": "10" + } + }, + { + "entity": "10.5", + "type": "builtin.number", + "startIndex": 206, + "endIndex": 209, + "resolution": { + "subtype": "decimal", + "value": "10.5" + } + }, + { + "entity": "425", + "type": "builtin.number", + "startIndex": 216, + "endIndex": 218, + "resolution": { + "subtype": "integer", + "value": "425" + } + }, + { + "entity": "555", + "type": "builtin.number", + "startIndex": 220, + "endIndex": 222, + "resolution": { + "subtype": "integer", + "value": "555" + } + }, + { + "entity": "1234", + "type": "builtin.number", + "startIndex": 224, + "endIndex": 227, + "resolution": { + "subtype": "integer", + "value": "1234" + } + }, + { + "entity": "3", + "type": "builtin.number", + "startIndex": 233, + "endIndex": 233, + "resolution": { + "subtype": "integer", + "value": "3" + } + }, + { + "entity": "-27.5", + "type": "builtin.number", + "startIndex": 247, + "endIndex": 251, + "resolution": { + "subtype": "decimal", + "value": "-27.5" + } + }, + { + "entity": "3rd", + "type": "builtin.ordinal", + "startIndex": 44, + "endIndex": 46, + "resolution": { + "value": "3" + } + }, + { + "entity": "first", + "type": "builtin.ordinal", + "startIndex": 188, + "endIndex": 192, + "resolution": { + "value": "1" + } + }, + { + "entity": "10%", + "type": "builtin.percentage", + "startIndex": 198, + "endIndex": 200, + "resolution": { + "value": "10%" + } + }, + { + "entity": "10.5%", + "type": "builtin.percentage", + "startIndex": 206, + "endIndex": 210, + "resolution": { + "value": "10.5%" + } + }, + { + "entity": "425-555-1234", + "type": "builtin.phonenumber", + "startIndex": 216, + "endIndex": 227, + "resolution": { + "score": "0.9", + "value": "425-555-1234" + } + }, + { + "entity": "3 degrees", + "type": "builtin.temperature", + "startIndex": 233, + "endIndex": 241, + "resolution": { + "unit": "Degree", + "value": "3" + } + }, + { + "entity": "-27.5 degrees c", + "type": "builtin.temperature", + "startIndex": 247, + "endIndex": 261, + "resolution": { + "unit": "C", + "value": "-27.5" + } + } + ], + "compositeEntities": [ + { + "parentType": "Composite1", + "value": "12 years old and 3 days old and monday july 3rd and every monday and between 3am and 5 : 30am and 4 acres and 4 pico meters and chrimc @ hotmail . com and $ 4 and $ 4 . 25 and also 32 and 210 . 4 and first and 10 % and 10 . 5 % and 425 - 555 - 1234 and 3 degrees and - 27 . 5 degrees c", + "children": [ + { + "type": "builtin.age", + "value": "12 years old" + }, + { + "type": "builtin.age", + "value": "3 days old" + }, + { + "type": "builtin.datetimeV2.duration", + "value": "12 years" + }, + { + "type": "builtin.datetimeV2.duration", + "value": "3 days" + }, + { + "type": "builtin.datetimeV2.date", + "value": "monday july 3rd" + }, + { + "type": "builtin.datetimeV2.set", + "value": "every monday" + }, + { + "type": "builtin.datetimeV2.timerange", + "value": "between 3am and 5:30am" + }, + { + "type": "builtin.dimension", + "value": "4 acres" + }, + { + "type": "builtin.dimension", + "value": "4 pico meters" + }, + { + "type": "builtin.email", + "value": "chrimc@hotmail.com" + }, + { + "type": "builtin.currency", + "value": "$4" + }, + { + "type": "builtin.currency", + "value": "$4.25" + }, + { + "type": "builtin.number", + "value": "12" + }, + { + "type": "builtin.number", + "value": "3" + }, + { + "type": "builtin.number", + "value": "5" + }, + { + "type": "builtin.number", + "value": "4" + }, + { + "type": "builtin.number", + "value": "4" + }, + { + "type": "builtin.number", + "value": "4" + }, + { + "type": "builtin.number", + "value": "4.25" + }, + { + "type": "builtin.number", + "value": "32" + }, + { + "type": "builtin.number", + "value": "210.4" + }, + { + "type": "builtin.number", + "value": "10" + }, + { + "type": "builtin.number", + "value": "10.5" + }, + { + "type": "builtin.number", + "value": "425" + }, + { + "type": "builtin.number", + "value": "555" + }, + { + "type": "builtin.number", + "value": "1234" + }, + { + "type": "builtin.number", + "value": "3" + }, + { + "type": "builtin.number", + "value": "-27.5" + }, + { + "type": "builtin.ordinal", + "value": "3rd" + }, + { + "type": "builtin.ordinal", + "value": "first" + }, + { + "type": "builtin.percentage", + "value": "10%" + }, + { + "type": "builtin.percentage", + "value": "10.5%" + }, + { + "type": "builtin.phonenumber", + "value": "425-555-1234" + }, + { + "type": "builtin.temperature", + "value": "3 degrees" + }, + { + "type": "builtin.temperature", + "value": "-27.5 degrees c" + } + ] + } + ], + "sentimentAnalysis": { + "label": "neutral", + "score": 0.5 + }, + "connectedServiceResult": null + } +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/TypedPrebuilt.json b/libraries/botbuilder-ai/tests/luis/test_data/TypedPrebuilt.json new file mode 100644 index 000000000..18e0a1263 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/TypedPrebuilt.json @@ -0,0 +1,177 @@ +{ + "Text": "http://foo.com is where you can get a weather forecast for seattle", + "Intents": { + "Weather_GetForecast": { + "score": 0.8973387 + }, + "EntityTests": { + "score": 0.6120084 + }, + "None": { + "score": 0.038558647 + }, + "search": { + "score": 0.0183345526 + }, + "Travel": { + "score": 0.00512401946 + }, + "Delivery": { + "score": 0.00396467233 + }, + "SpecifyName": { + "score": 0.00337156886 + }, + "Help": { + "score": 0.00175959955 + }, + "Cancel": { + "score": 0.000602799933 + }, + "Greeting": { + "score": 0.000445256825 + } + }, + "Entities": { + "Composite2": [ + { + "url": [ + "http://foo.com" + ], + "Weather_Location": [ + "seattle" + ], + "$instance": { + "url": [ + { + "startIndex": 0, + "endIndex": 14, + "text": "http://foo.com", + "type": "builtin.url" + } + ], + "Weather_Location": [ + { + "startIndex": 59, + "endIndex": 66, + "text": "seattle", + "score": 0.8812625, + "type": "Weather.Location" + } + ] + } + } + ], + "$instance": { + "Composite2": [ + { + "startIndex": 0, + "endIndex": 66, + "text": "http : / / foo . com is where you can get a weather forecast for seattle", + "score": 0.572650731, + "type": "Composite2" + } + ] + } + }, + "sentiment": { + "label": "neutral", + "score": 0.5 + }, + "luisResult": { + "query": "http://foo.com is where you can get a weather forecast for seattle", + "alteredQuery": null, + "topScoringIntent": { + "intent": "Weather.GetForecast", + "score": 0.8973387 + }, + "intents": [ + { + "intent": "Weather.GetForecast", + "score": 0.8973387 + }, + { + "intent": "EntityTests", + "score": 0.6120084 + }, + { + "intent": "None", + "score": 0.038558647 + }, + { + "intent": "search", + "score": 0.0183345526 + }, + { + "intent": "Travel", + "score": 0.00512401946 + }, + { + "intent": "Delivery", + "score": 0.00396467233 + }, + { + "intent": "SpecifyName", + "score": 0.00337156886 + }, + { + "intent": "Help", + "score": 0.00175959955 + }, + { + "intent": "Cancel", + "score": 0.000602799933 + }, + { + "intent": "Greeting", + "score": 0.000445256825 + } + ], + "entities": [ + { + "entity": "seattle", + "type": "Weather.Location", + "startIndex": 59, + "endIndex": 65, + "score": 0.8812625 + }, + { + "entity": "http : / / foo . com is where you can get a weather forecast for seattle", + "type": "Composite2", + "startIndex": 0, + "endIndex": 65, + "score": 0.572650731 + }, + { + "entity": "http://foo.com", + "type": "builtin.url", + "startIndex": 0, + "endIndex": 13, + "resolution": { + "value": "http://foo.com" + } + } + ], + "compositeEntities": [ + { + "parentType": "Composite2", + "value": "http : / / foo . com is where you can get a weather forecast for seattle", + "children": [ + { + "type": "Weather.Location", + "value": "seattle" + }, + { + "type": "builtin.url", + "value": "http://foo.com" + } + ] + } + ], + "sentimentAnalysis": { + "label": "neutral", + "score": 0.5 + }, + "connectedServiceResult": null + } +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/luis/test_data/V1DatetimeResolution.json b/libraries/botbuilder-ai/tests/luis/test_data/V1DatetimeResolution.json new file mode 100644 index 000000000..eb662c3ff --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/test_data/V1DatetimeResolution.json @@ -0,0 +1,19 @@ +{ + "query": "4", + "topScoringIntent": { + "intent": "None", + "score": 0.8575135 + }, + "entities": [ + { + "entity": "4", + "type": "builtin.datetime.time", + "startIndex": 0, + "endIndex": 0, + "resolution": { + "comment": "ampm", + "time": "T04" + } + } + ] +} \ No newline at end of file From 027f8cd43958dfcc578e638c3b5403ae27498b8f Mon Sep 17 00:00:00 2001 From: congysu Date: Sat, 27 Apr 2019 13:16:53 -0700 Subject: [PATCH 55/73] add test for multi-intents etc. * add tests for multi-intents, prebuilt entities * add top intent with named tuple --- .../botbuilder/ai/luis/__init__.py | 3 +- .../botbuilder/ai/luis/luis_util.py | 12 ++--- .../botbuilder/ai/luis/recognizer_result.py | 19 +++++--- .../tests/luis/luis_recognizer_test.py | 45 ++++++++++++++++++- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py index 7bbeb68cd..8f51b13d2 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/__init__.py @@ -5,7 +5,7 @@ from .luis_application import LuisApplication from .luis_prediction_options import LuisPredictionOptions from .luis_telemetry_constants import LuisTelemetryConstants -from .recognizer_result import RecognizerResult +from .recognizer_result import RecognizerResult, TopIntent from .luis_recognizer import LuisRecognizer __all__ = [ @@ -15,4 +15,5 @@ "LuisRecognizer", "LuisTelemetryConstants", "RecognizerResult", + "TopIntent", ] diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index b80c6d24b..7ad335914 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -104,12 +104,12 @@ def extract_entity_value(entity: EntityModel) -> object: if entity.type.startswith("builtin.datetime."): return resolution elif entity.type.startswith("builtin.datetimeV2."): - if not resolution.values: + if not resolution["values"]: return resolution - resolution_values = resolution.values - val_type = resolution.values[0].type - timexes = [val.timex for val in resolution_values] + resolution_values = resolution["values"] + val_type = resolution["values"][0]["type"] + timexes = [val["timex"] for val in resolution_values] distinct_timexes = list(set(timexes)) return {"type": val_type, "timex": distinct_timexes} else: @@ -152,8 +152,8 @@ def extract_entity_metadata(entity: EntityModel) -> Dict: obj["score"] = float(entity.additional_properties["score"]) resolution = entity.additional_properties.get("resolution") - if resolution is not None and resolution.subtype is not None: - obj["subtype"] = resolution.subtype + if resolution is not None and resolution.get("subtype") is not None: + obj["subtype"] = resolution["subtype"] return obj diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py index 11db4b4ab..28d2a4f63 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py @@ -1,11 +1,18 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Dict, NamedTuple, Tuple +from typing import Dict, NamedTuple from . import IntentScore +class TopIntent(NamedTuple): + """The top scoring intent and its score.""" + + intent: str + score: float + + class RecognizerResult: """ Contains recognition results generated by a recognizer. @@ -137,22 +144,20 @@ def properties(self, value: Dict[str, object]) -> None: self._properties = value - def get_top_scoring_intent( - self - ) -> NamedTuple("TopIntent", intent=str, score=float): + def get_top_scoring_intent(self) -> TopIntent: """Return the top scoring intent and its score. :return: Intent and score. - :rtype: NamedTuple("TopIntent", intent=str, score=float) + :rtype: TopIntent """ if self.intents is None: raise TypeError("result.intents can't be None") - top_intent: Tuple[str, float] = ("", 0.0) + top_intent = TopIntent(intent="", score=0.0) for intent_name, intent_score in self.intents.items(): score = intent_score.score if score > top_intent[1]: - top_intent = (intent_name, score) + top_intent = TopIntent(intent_name, score) return top_intent diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index 81ef24abf..9b3d14209 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -7,7 +7,13 @@ from msrest import Deserializer from requests.models import Response -from botbuilder.ai.luis import LuisApplication, LuisPredictionOptions, LuisRecognizer +from botbuilder.ai.luis import ( + LuisApplication, + LuisPredictionOptions, + LuisRecognizer, + RecognizerResult, + TopIntent, +) from botbuilder.core import TurnContext from botbuilder.core.adapters import TestAdapter from botbuilder.schema import ( @@ -145,6 +151,43 @@ def test_null_utterance(self): self.assertIsNotNone(result.entities) self.assertEqual(0, len(result.entities)) + def test_MultipleIntents_PrebuiltEntity(self): + utterance: str = "Please deliver February 2nd 2001" + response_path: str = "MultipleIntents_PrebuiltEntity.json" + + result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + + self.assertIsNotNone(result) + self.assertEqual(utterance, result.text) + self.assertIsNotNone(result.intents) + self.assertTrue(len(result.intents) > 1) + self.assertIsNotNone(result.intents["Delivery"]) + self.assertTrue( + result.intents["Delivery"].score > 0 + and result.intents["Delivery"].score <= 1 + ) + self.assertEqual("Delivery", result.get_top_scoring_intent().intent) + self.assertTrue(result.get_top_scoring_intent().score > 0) + self.assertIsNotNone(result.entities) + self.assertIsNotNone(result.entities["number"]) + self.assertEqual(2001, int(result.entities["number"][0])) + self.assertIsNotNone(result.entities["ordinal"]) + self.assertEqual(2, int(result.entities["ordinal"][0])) + self.assertIsNotNone(result.entities["datetime"][0]) + self.assertEqual("2001-02-02", result.entities["datetime"][0]["timex"][0]) + self.assertIsNotNone(result.entities["$instance"]["number"]) + self.assertEqual( + 28, int(result.entities["$instance"]["number"][0]["startIndex"]) + ) + self.assertEqual(32, int(result.entities["$instance"]["number"][0]["endIndex"])) + self.assertEqual("2001", result.text[28:32]) + self.assertIsNotNone(result.entities["$instance"]["datetime"]) + self.assertEqual(15, result.entities["$instance"]["datetime"][0]["startIndex"]) + self.assertEqual(32, result.entities["$instance"]["datetime"][0]["endIndex"]) + self.assertEqual( + "february 2nd 2001", result.entities["$instance"]["datetime"][0]["text"] + ) + def assert_score(self, score: float): self.assertTrue(score >= 0) self.assertTrue(score <= 1) From f621b93ba7fdca24d9215bd85d357ea1b594af0d Mon Sep 17 00:00:00 2001 From: congysu Date: Sat, 27 Apr 2019 15:38:21 -0700 Subject: [PATCH 56/73] tests for list entity etc * prebuilt entity with multiple values * list entity with single value * list entity with multiple values --- .../botbuilder/ai/luis/luis_util.py | 8 +-- .../tests/luis/luis_recognizer_test.py | 66 ++++++++++++++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index 7ad335914..053023b46 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -114,9 +114,9 @@ def extract_entity_value(entity: EntityModel) -> object: return {"type": val_type, "timex": distinct_timexes} else: if entity.type in {"builtin.number", "builtin.ordinal"}: - return LuisUtil.number(resolution.value) + return LuisUtil.number(resolution["value"]) elif entity.type == "builtin.percentage": - svalue = str(resolution.value) + svalue = str(resolution["value"]) if svalue.endswith("%"): svalue = svalue[:-1] @@ -128,7 +128,7 @@ def extract_entity_value(entity: EntityModel) -> object: "builtin.temperature", }: units = str(resolution.unit) - val = LuisUtil.number(resolution.value) + val = LuisUtil.number(resolution["value"]) obj = {} if val is not None: obj["number"] = val @@ -136,7 +136,7 @@ def extract_entity_value(entity: EntityModel) -> object: obj["units"] = units return obj else: - return resolution.value or resolution.values + return resolution.get("value") or resolution.get("values") @staticmethod def extract_entity_metadata(entity: EntityModel) -> Dict: diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index 9b3d14209..b37f90e5a 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import json import unittest from os import path @@ -151,7 +154,7 @@ def test_null_utterance(self): self.assertIsNotNone(result.entities) self.assertEqual(0, len(result.entities)) - def test_MultipleIntents_PrebuiltEntity(self): + def test_multiple_intents_prebuilt_entity(self): utterance: str = "Please deliver February 2nd 2001" response_path: str = "MultipleIntents_PrebuiltEntity.json" @@ -188,6 +191,67 @@ def test_MultipleIntents_PrebuiltEntity(self): "february 2nd 2001", result.entities["$instance"]["datetime"][0]["text"] ) + def test_multiple_intents_prebuilt_entities_with_multi_values(self): + utterance: str = "Please deliver February 2nd 2001 in room 201" + response_path: str = "MultipleIntents_PrebuiltEntitiesWithMultiValues.json" + + result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.text) + self.assertEqual(utterance, result.text) + self.assertIsNotNone(result.intents) + self.assertIsNotNone(result.intents["Delivery"]) + self.assertIsNotNone(result.entities) + self.assertIsNotNone(result.entities["number"]) + self.assertEqual(2, len(result.entities["number"])) + self.assertTrue(201 in map(int, result.entities["number"])) + self.assertTrue(2001 in map(int, result.entities["number"])) + self.assertIsNotNone(result.entities["datetime"][0]) + self.assertEqual("2001-02-02", result.entities["datetime"][0]["timex"][0]) + + def test_multiple_intents_list_entity_with_single_value(self): + utterance: str = "I want to travel on united" + response_path: str = "MultipleIntents_ListEntityWithSingleValue.json" + + result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.text) + self.assertEqual(utterance, result.text) + self.assertIsNotNone(result.intents) + self.assertIsNotNone(result.intents["Travel"]) + self.assertIsNotNone(result.entities) + self.assertIsNotNone(result.entities["Airline"]) + self.assertEqual("United", result.entities["Airline"][0][0]) + self.assertIsNotNone(result.entities["$instance"]) + self.assertIsNotNone(result.entities["$instance"]["Airline"]) + self.assertEqual(20, result.entities["$instance"]["Airline"][0]["startIndex"]) + self.assertEqual(26, result.entities["$instance"]["Airline"][0]["endIndex"]) + self.assertEqual("united", result.entities["$instance"]["Airline"][0]["text"]) + + def test_multiple_intents_list_entity_with_multi_values(self): + utterance: str = "I want to travel on DL" + response_path: str = "MultipleIntents_ListEntityWithMultiValues.json" + + result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.text) + self.assertEqual(utterance, result.text) + self.assertIsNotNone(result.intents) + self.assertIsNotNone(result.intents["Travel"]) + self.assertIsNotNone(result.entities) + self.assertIsNotNone(result.entities["Airline"]) + self.assertEqual(2, len(result.entities["Airline"][0])) + self.assertTrue("Delta" in result.entities["Airline"][0]) + self.assertTrue("Virgin" in result.entities["Airline"][0]) + self.assertIsNotNone(result.entities["$instance"]) + self.assertIsNotNone(result.entities["$instance"]["Airline"]) + self.assertEqual(20, result.entities["$instance"]["Airline"][0]["startIndex"]) + self.assertEqual(22, result.entities["$instance"]["Airline"][0]["endIndex"]) + self.assertEqual("dl", result.entities["$instance"]["Airline"][0]["text"]) + def assert_score(self, score: float): self.assertTrue(score >= 0) self.assertTrue(score <= 1) From 1526bad5ba9631b2b367f704a7fd35539d26a02b Mon Sep 17 00:00:00 2001 From: congysu Date: Sat, 27 Apr 2019 16:51:01 -0700 Subject: [PATCH 57/73] test for composite entity --- .../botbuilder/ai/luis/luis_util.py | 6 +-- .../tests/luis/luis_recognizer_test.py | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index 053023b46..12fa2ac2b 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -214,7 +214,7 @@ def populate_composite_entity_model( ) children_entities[LuisUtil._metadata_key] = {} - covered_set: Set[EntityModel] = set() + covered_set: List[EntityModel] = [] for child in composite_entity.children: for entity in entities: # We already covered this entity @@ -222,13 +222,13 @@ def populate_composite_entity_model( continue # This entity doesn't belong to this composite entity - if child.Type != entity.Type or not LuisUtil.composite_contains_entity( + if child.type != entity.type or not LuisUtil.composite_contains_entity( composite_entity_metadata, entity ): continue # Add to the set to ensure that we don't consider the same child entity more than once per composite - covered_set.add(entity) + covered_set.append(entity) LuisUtil.add_property( children_entities, LuisUtil.extract_normalized_entity_name(entity), diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index b37f90e5a..fa6ebf65d 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -252,6 +252,56 @@ def test_multiple_intents_list_entity_with_multi_values(self): self.assertEqual(22, result.entities["$instance"]["Airline"][0]["endIndex"]) self.assertEqual("dl", result.entities["$instance"]["Airline"][0]["text"]) + def test_multiple_intents_composite_entity_model(self): + utterance: str = "Please deliver it to 98033 WA" + response_path: str = "MultipleIntents_CompositeEntityModel.json" + + result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.text) + self.assertEqual(utterance, result.text) + self.assertIsNotNone(result.intents) + self.assertIsNotNone(result.intents["Delivery"]) + self.assertIsNotNone(result.entities) + self.assertIsNone(result.entities.get("number")) + self.assertIsNone(result.entities.get("State")) + self.assertIsNotNone(result.entities["Address"]) + self.assertEqual(98033, result.entities["Address"][0]["number"][0]) + self.assertEqual("wa", result.entities["Address"][0]["State"][0]) + self.assertIsNotNone(result.entities["$instance"]) + self.assertIsNone(result.entities["$instance"].get("number")) + self.assertIsNone(result.entities["$instance"].get("State")) + self.assertIsNotNone(result.entities["$instance"]["Address"]) + self.assertEqual(21, result.entities["$instance"]["Address"][0]["startIndex"]) + self.assertEqual(29, result.entities["$instance"]["Address"][0]["endIndex"]) + self.assert_score(result.entities["$instance"]["Address"][0]["score"]) + self.assertIsNotNone(result.entities["Address"][0]["$instance"]) + self.assertIsNotNone(result.entities["Address"][0]["$instance"]["number"]) + self.assertEqual( + 21, result.entities["Address"][0]["$instance"]["number"][0]["startIndex"] + ) + self.assertEqual( + 26, result.entities["Address"][0]["$instance"]["number"][0]["endIndex"] + ) + self.assertEqual( + "98033", result.entities["Address"][0]["$instance"]["number"][0]["text"] + ) + self.assertIsNotNone(result.entities["Address"][0]["$instance"]["State"]) + self.assertEqual( + 27, result.entities["Address"][0]["$instance"]["State"][0]["startIndex"] + ) + self.assertEqual( + 29, result.entities["Address"][0]["$instance"]["State"][0]["endIndex"] + ) + self.assertEqual( + "wa", result.entities["Address"][0]["$instance"]["State"][0]["text"] + ) + self.assertEqual("WA", result.text[27:29]) + self.assert_score( + result.entities["Address"][0]["$instance"]["State"][0]["score"] + ) + def assert_score(self, score: float): self.assertTrue(score >= 0) self.assertTrue(score <= 1) From 396691ad54c02596eea5668aedc2d1f1c2dc6708 Mon Sep 17 00:00:00 2001 From: congysu Date: Sun, 28 Apr 2019 09:23:46 -0700 Subject: [PATCH 58/73] add test for datetime entity * test for multiple datetime entities * test for v1 datetime resolution --- .../botbuilder/ai/luis/luis_util.py | 3 +- .../tests/luis/luis_recognizer_test.py | 39 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index 12fa2ac2b..befcfc1d1 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from collections import OrderedDict from typing import Dict, List, Set, Union from azure.cognitiveservices.language.luis.runtime.models import ( @@ -110,7 +111,7 @@ def extract_entity_value(entity: EntityModel) -> object: resolution_values = resolution["values"] val_type = resolution["values"][0]["type"] timexes = [val["timex"] for val in resolution_values] - distinct_timexes = list(set(timexes)) + distinct_timexes = list(OrderedDict.fromkeys(timexes)) return {"type": val_type, "timex": distinct_timexes} else: if entity.type in {"builtin.number", "builtin.ordinal"}: diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index fa6ebf65d..b57f5ce7c 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -302,16 +302,49 @@ def test_multiple_intents_composite_entity_model(self): result.entities["Address"][0]["$instance"]["State"][0]["score"] ) - def assert_score(self, score: float): + def test_multiple_date_time_entities(self): + utterance: str = "Book a table on Friday or tomorrow at 5 or tomorrow at 4" + response_path: str = "MultipleDateTimeEntities.json" + + result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + + self.assertIsNotNone(result.entities["datetime"]) + self.assertEqual(3, len(result.entities["datetime"])) + self.assertEqual(1, len(result.entities["datetime"][0]["timex"])) + self.assertEqual("XXXX-WXX-5", result.entities["datetime"][0]["timex"][0]) + self.assertEqual(1, len(result.entities["datetime"][0]["timex"])) + self.assertEqual(2, len(result.entities["datetime"][1]["timex"])) + self.assertEqual(2, len(result.entities["datetime"][2]["timex"])) + self.assertTrue(result.entities["datetime"][1]["timex"][0].endswith("T05")) + self.assertTrue(result.entities["datetime"][1]["timex"][1].endswith("T17")) + self.assertTrue(result.entities["datetime"][2]["timex"][0].endswith("T04")) + self.assertTrue(result.entities["datetime"][2]["timex"][1].endswith("T16")) + self.assertEqual(3, len(result.entities["$instance"]["datetime"])) + + def test_v1_datetime_resolution(self): + utterance: str = "at 4" + response_path: str = "V1DatetimeResolution.json" + + result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + + self.assertIsNotNone(result.entities["datetime_time"]) + self.assertEqual(1, len(result.entities["datetime_time"])) + self.assertEqual("ampm", result.entities["datetime_time"][0]["comment"]) + self.assertEqual("T04", result.entities["datetime_time"][0]["time"]) + self.assertEqual(1, len(result.entities["$instance"]["datetime_time"])) + + def assert_score(self, score: float) -> None: self.assertTrue(score >= 0) self.assertTrue(score <= 1) @classmethod - def _get_recognizer_result(cls, utterance: str, response_file: str): + def _get_recognizer_result( + cls, utterance: str, response_file: str + ) -> RecognizerResult: 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") as f: + with open(response_path, "r", encoding="utf-8-sig") as f: response_str = f.read() response_json = json.loads(response_str) From 80fe1f08b3ceb1d4e72fe782e64dad2c9b511190 Mon Sep 17 00:00:00 2001 From: congysu Date: Sun, 28 Apr 2019 12:48:24 -0700 Subject: [PATCH 59/73] add test for top intent --- .../botbuilder/ai/luis/luis_recognizer.py | 12 +++---- .../botbuilder/ai/luis/recognizer_result.py | 4 +-- .../tests/luis/luis_recognizer_test.py | 34 +++++++++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 66b74edb9..a6cc2a499 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -115,11 +115,9 @@ def telemetry_client(self, value: BotTelemetryClient): self._telemetry_client = value + @staticmethod def top_intent( - self, - results: RecognizerResult, - default_intent: str = "None", - min_score: float = 0.0, + results: RecognizerResult, default_intent: str = "None", min_score: float = 0.0 ) -> str: """Returns the name of the top scoring intent from a set of LUIS results. @@ -140,10 +138,10 @@ def top_intent( top_intent: str = None top_score: float = -1.0 if results.intents: - for intent, intent_score in results.intents.items(): - score = float(intent_score) + for intent_name, intent_score in results.intents.items(): + score = intent_score.score if score > top_score and score >= min_score: - top_intent = intent + top_intent = intent_name top_score = score return top_intent or default_intent diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py index 28d2a4f63..7a91761ac 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/recognizer_result.py @@ -23,12 +23,12 @@ def __init__( text: str = None, altered_text: str = None, intents: Dict[str, IntentScore] = None, - entities: Dict = None, + entities: Dict[str, object] = None, ): self._text: str = text self._altered_text: str = altered_text self._intents: Dict[str, IntentScore] = intents - self._entities: Dict = entities + self._entities: Dict[str, object] = entities self._properties: Dict[str, object] = {} @property diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index b57f5ce7c..be3f262cd 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -11,6 +11,7 @@ from requests.models import Response from botbuilder.ai.luis import ( + IntentScore, LuisApplication, LuisPredictionOptions, LuisRecognizer, @@ -32,6 +33,12 @@ class LuisRecognizerTest(unittest.TestCase): _subscriptionKey: str = "048ec46dc58e495482b0c447cfdbd291" _endpoint: str = "https://westus.api.cognitive.microsoft.com" + def __init__(self, *args, **kwargs): + super(LuisRecognizerTest, self).__init__(*args, **kwargs) + self._mocked_results: RecognizerResult = RecognizerResult( + intents={"Test": IntentScore(score=0.2), "Greeting": IntentScore(score=0.4)} + ) + def test_luis_recognizer_construction(self): # Arrange endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q=" @@ -333,6 +340,33 @@ def test_v1_datetime_resolution(self): self.assertEqual("T04", result.entities["datetime_time"][0]["time"]) self.assertEqual(1, len(result.entities["$instance"]["datetime_time"])) + def test_top_intent_returns_top_intent(self): + greeting_intent: str = LuisRecognizer.top_intent(self._mocked_results) + self.assertEqual(greeting_intent, "Greeting") + + def test_top_intent_returns_default_intent_if_min_score_is_higher(self): + default_intent: str = LuisRecognizer.top_intent( + self._mocked_results, min_score=0.5 + ) + self.assertEqual(default_intent, "None") + + def test_top_intent_returns_default_intent_if_provided(self): + default_intent: str = LuisRecognizer.top_intent( + self._mocked_results, "Test2", 0.5 + ) + self.assertEqual(default_intent, "Test2") + + def test_top_intent_throws_type_error_if_results_is_none(self): + none_results: RecognizerResult = None + with self.assertRaises(TypeError): + LuisRecognizer.top_intent(none_results) + + def test_top_intent_returns_top_intent_if_score_equals_min_score(self): + default_intent: str = LuisRecognizer.top_intent( + self._mocked_results, min_score=0.4 + ) + self.assertEqual(default_intent, "Greeting") + def assert_score(self, score: float) -> None: self.assertTrue(score >= 0) self.assertTrue(score <= 1) From a56b55f0550fe75d91fd3b541837e9759ec51387 Mon Sep 17 00:00:00 2001 From: Dave Taniguchi Date: Sun, 28 Apr 2019 13:04:48 -0700 Subject: [PATCH 60/73] Support and sample for flask/django --- .../appinsights_bot_telemetry_client.py | 123 ++++++++++++++++++ .../application_insights_telemetry_client.py | 33 ++++- .../integration_post_data.py | 82 ++++++++++++ .../django_sample/django_sample/__init__.py | 0 .../django_sample/django_sample/settings.py | 120 +++++++++++++++++ .../django_sample/django_sample/urls.py | 22 ++++ .../django_sample/django_sample/wsgi.py | 16 +++ .../samples/django_sample/manage.py | 21 +++ .../samples/django_sample/myapp/__init__.py | 0 .../samples/django_sample/myapp/admin.py | 3 + .../samples/django_sample/myapp/apps.py | 5 + .../django_sample/myapp/custom_session.py | 6 + .../myapp/migrations/__init__.py | 0 .../samples/django_sample/myapp/models.py | 3 + .../samples/django_sample/myapp/tests.py | 3 + .../samples/django_sample/myapp/urls.py | 8 ++ .../samples/django_sample/myapp/views.py | 19 +++ .../samples/flask_sample.py | 27 ++++ .../botbuilder-applicationinsights/setup.py | 15 ++- .../tests/__init__.py | 0 libraries/botbuilder-dialogs/setup.py | 8 +- 21 files changed, 506 insertions(+), 8 deletions(-) create mode 100644 libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/appinsights_bot_telemetry_client.py create mode 100644 libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/integration_post_data.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/__init__.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/settings.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/urls.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/wsgi.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/manage.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/myapp/__init__.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/myapp/admin.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/myapp/apps.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/myapp/custom_session.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/myapp/migrations/__init__.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/myapp/models.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/myapp/tests.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/myapp/urls.py create mode 100644 libraries/botbuilder-applicationinsights/samples/django_sample/myapp/views.py create mode 100644 libraries/botbuilder-applicationinsights/samples/flask_sample.py create mode 100644 libraries/botbuilder-applicationinsights/tests/__init__.py diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/appinsights_bot_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/appinsights_bot_telemetry_client.py new file mode 100644 index 000000000..da097991c --- /dev/null +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/appinsights_bot_telemetry_client.py @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import sys +import traceback +from applicationinsights import TelemetryClient +from botbuilder.core.bot_telemetry_client import BotTelemetryClient, TelemetryDataPointType +from typing import Dict + +class AppinsightsBotTelemetryClient(BotTelemetryClient): + + def __init__(self, instrumentation_key:str): + self._instrumentation_key = instrumentation_key + + self._context = TelemetryContext() + context.instrumentation_key = self._instrumentation_key + # context.user.id = 'BOTID' # telemetry_channel.context.session. + # context.session.id = 'BOTSESSION' + + # set up channel with context + self._channel = TelemetryChannel(context) + # telemetry_channel.context.properties['my_property'] = 'my_value' + + self._client = TelemetryClient(self._instrumentation_key, self._channel) + + + def track_pageview(self, name: str, url:str, duration: int = 0, properties : Dict[str, object]=None, + measurements: Dict[str, object]=None) -> None: + """ + Send information about the page viewed in the application (a web page for instance). + :param name: the name of the page that was viewed. + :param url: the URL of the page that was viewed. + :param duration: the duration of the page view in milliseconds. (defaults to: 0) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + self._client.track_pageview(name, url, duration, properties, measurements) + + def track_exception(self, type_exception: type = None, value : Exception =None, tb : traceback =None, + properties: Dict[str, object]=None, measurements: Dict[str, object]=None) -> None: + """ + Send information about a single exception that occurred in the application. + :param type_exception: the type of the exception that was thrown. + :param value: the exception that the client wants to send. + :param tb: the traceback information as returned by :func:`sys.exc_info`. + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + self._client.track_exception(type_exception, value, tb, properties, measurements) + + def track_event(self, name: str, properties: Dict[str, object] = None, + measurements: Dict[str, object] = None) -> None: + """ + Send information about a single event that has occurred in the context of the application. + :param name: the data to associate to this event. + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + """ + self._client.track_event(name, properties, measurements) + + def track_metric(self, name: str, value: float, type: TelemetryDataPointType =None, + count: int =None, min: float=None, max: float=None, std_dev: float=None, + properties: Dict[str, object]=None) -> NotImplemented: + """ + Send information about a single metric data point that was captured for the application. + :param name: The name of the metric that was captured. + :param value: The value of the metric that was captured. + :param type: The type of the metric. (defaults to: TelemetryDataPointType.aggregation`) + :param count: the number of metrics that were aggregated into this data point. (defaults to: None) + :param min: the minimum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param max: the maximum of all metrics collected that were aggregated into this data point. (defaults to: None) + :param std_dev: the standard deviation of all metrics collected that were aggregated into this data point. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + """ + self._client.track_metric(name, value, type, count, min, max, std_dev, properties) + + def track_trace(self, name: str, properties: Dict[str, object]=None, severity=None): + """ + Sends a single trace statement. + :param name: the trace statement.\n + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None)\n + :param severity: the severity level of this trace, one of DEBUG, INFO, WARNING, ERROR, CRITICAL + """ + self._client.track_trace(name, properties, severity) + + def track_request(self, name: str, url: str, success: bool, start_time: str=None, + duration: int=None, response_code: str =None, http_method: str=None, + properties: Dict[str, object]=None, measurements: Dict[str, object]=None, + request_id: str=None): + """ + Sends a single request that was captured for the application. + :param name: The name for this request. All requests with the same name will be grouped together. + :param url: The actual URL for this request (to show in individual request instances). + :param success: True if the request ended in success, False otherwise. + :param start_time: the start time of the request. The value should look the same as the one returned by :func:`datetime.isoformat()` (defaults to: None) + :param duration: the number of milliseconds that this request lasted. (defaults to: None) + :param response_code: the response code that this request returned. (defaults to: None) + :param http_method: the HTTP method that triggered this request. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param request_id: the id for this request. If None, a new uuid will be generated. (defaults to: None) + """ + self._client.track_request(name, url, success, start_time, duration, response_code, http_method, properties, + measurements, request_id) + + def track_dependency(self, name:str, data:str, type:str=None, target:str=None, duration:int=None, + success:bool=None, result_code:str=None, properties:Dict[str, object]=None, + measurements:Dict[str, object]=None, dependency_id:str=None): + """ + Sends a single dependency telemetry that was captured for the application. + :param name: the name of the command initiated with this dependency call. Low cardinality value. Examples are stored procedure name and URL path template. + :param data: the command initiated by this dependency call. Examples are SQL statement and HTTP URL with all query parameters. + :param type: the dependency type name. Low cardinality value for logical grouping of dependencies and interpretation of other fields like commandName and resultCode. Examples are SQL, Azure table, and HTTP. (default to: None) + :param target: the target site of a dependency call. Examples are server name, host address. (default to: None) + :param duration: the number of milliseconds that this dependency call lasted. (defaults to: None) + :param success: true if the dependency call ended in success, false otherwise. (defaults to: None) + :param result_code: the result code of a dependency call. Examples are SQL error code and HTTP status code. (defaults to: None) + :param properties: the set of custom properties the client wants attached to this data item. (defaults to: None) + :param measurements: the set of custom measurements the client wants to attach to this data item. (defaults to: None) + :param id: the id for this dependency call. If None, a new uuid will be generated. (defaults to: None) + """ + self._client.track_dependency(name, data, type, target, duration, success, result_code, properties, + measurements, dependency_id) + diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py index 0fe48267b..b226e7dd9 100644 --- a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/application_insights_telemetry_client.py @@ -5,14 +5,36 @@ from applicationinsights import TelemetryClient from botbuilder.core.bot_telemetry_client import BotTelemetryClient, TelemetryDataPointType from typing import Dict +from .integration_post_data import IntegrationPostData class ApplicationInsightsTelemetryClient(BotTelemetryClient): - + def __init__(self, instrumentation_key:str): self._instrumentation_key = instrumentation_key self._client = TelemetryClient(self._instrumentation_key) + # Telemetry Processor + def telemetry_processor(data, context): + post_data = IntegrationPostData().activity_json + # Override session and user id + from_prop = post_data['from'] if 'from' in post_data else None + user_id = from_prop['id'] if from_prop != None else None + channel_id = post_data['channelId'] if 'channelId' in post_data else None + conversation = post_data['conversation'] if 'conversation' in post_data else None + conversation_id = conversation['id'] if 'id' in conversation else None + context.user.id = channel_id + user_id + context.session.id = conversation_id + + # Additional bot-specific properties + if 'activityId' in post_data: + data.properties["activityId"] = post_data['activityId'] + if 'channelId' in post_data: + data.properties["channelId"] = post_data['channelId'] + if 'activityType' in post_data: + data.properties["activityType"] = post_data['activityType'] + + self._client.add_telemetry_processor(telemetry_processor) - + def track_pageview(self, name: str, url:str, duration: int = 0, properties : Dict[str, object]=None, measurements: Dict[str, object]=None) -> None: """ @@ -111,3 +133,10 @@ def track_dependency(self, name:str, data:str, type:str=None, target:str=None, d self._client.track_dependency(name, data, type, target, duration, success, result_code, properties, measurements, dependency_id) + def flush(self): + """Flushes data in the queue. Data in the queue will be sent either immediately irrespective of what sender is + being used. + """ + self._client.flush() + + diff --git a/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/integration_post_data.py b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/integration_post_data.py new file mode 100644 index 000000000..4d5a1ae95 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/botbuilder/applicationinsights/integration_post_data.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import sys +import gc +import imp +import json +from botbuilder.schema import Activity + +class IntegrationPostData: + """ + Retrieve the POST body from the underlying framework: + - Flask + - Django + - (soon Tornado?) + + This class: + - Detects framework (currently flask or django) + - Pulls the current request body as a string + + Usage: + botdata = BotTelemetryData() + body = botdata.activity_json # Get current request body as json object + activity_id = body[id] # Get the ID from the POST body + """ + def __init__(self): + pass + + @property + def activity_json(self) -> json: + body_text = self.get_request_body() + #print(f"ACTIVITY_JSON: Body{body_text}", file=sys.stderr) + body = json.loads(body_text) if body_text != None else None + return body + + def get_request_body(self) -> str: + if self.detect_flask(): + flask_app = self.get_flask_app() + + with flask_app.app_context(): + mod = __import__('flask', fromlist=['Flask']) + request = getattr(mod, 'request') + body = self.body_from_WSGI_environ(request.environ) + return body + else: + if self.detect_django(): + mod = __import__('django.http', fromlist=['http']) + http_request = getattr(mod, 'HttpRequest') + django_requests = [o for o in gc.get_objects() if isinstance(o, http_request)] + django_request_instances = len(django_requests) + if django_request_instances != 1: + raise Exception(f'Detected {django_request_instances} instances of Django Requests. Expecting 1.') + request = django_requests[0] + body_unicode = request.body.decode('utf-8') if request.method == "POST" else None + return body_unicode + + def body_from_WSGI_environ(self, environ): + try: + request_body_size = int(environ.get('CONTENT_LENGTH', 0)) + except (ValueError): + request_body_size = 0 + request_body = environ['wsgi.input'].read(request_body_size) + return request_body + + def detect_flask(self) -> bool: + return "flask" in sys.modules + + def detect_django(self) -> bool: + return "django" in sys.modules + + def resolve_flask_type(self) -> 'Flask': + mod = __import__('flask', fromlist=['Flask']) + flask_type = getattr(mod, 'Flask') + return flask_type + + def get_flask_app(self) -> 'Flask': + flask = [o for o in gc.get_objects() if isinstance(o, self.resolve_flask_type())] + flask_instances = len(flask) + if flask_instances <= 0 or flask_instances > 1: + raise Exception(f'Detected {flask_instances} instances of flask. Expecting 1.') + app = flask[0] + return app \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/__init__.py b/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/settings.py b/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/settings.py new file mode 100644 index 000000000..23ca327dc --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/settings.py @@ -0,0 +1,120 @@ +""" +Django settings for django_sample project. + +Generated by 'django-admin startproject' using Django 2.2. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.2/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'rf#-23wei#$12uuwh25s=y29zi8-e86a&sfpo#mb6^q&z(q=lu' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'django_sample.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'django_sample.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/2.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/2.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.2/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/urls.py b/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/urls.py new file mode 100644 index 000000000..94edda341 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/urls.py @@ -0,0 +1,22 @@ +"""django_sample URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/2.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path('myapp/', include('myapp.urls')), + path('admin/', admin.site.urls), +] diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/wsgi.py b/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/wsgi.py new file mode 100644 index 000000000..ae2da4761 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/django_sample/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for django_sample project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_sample.settings') + +application = get_wsgi_application() diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/manage.py b/libraries/botbuilder-applicationinsights/samples/django_sample/manage.py new file mode 100644 index 000000000..653e66412 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_sample.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/__init__.py b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/admin.py b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/apps.py b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/apps.py new file mode 100644 index 000000000..74d6d1318 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MyappConfig(AppConfig): + name = 'myapp' diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/custom_session.py b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/custom_session.py new file mode 100644 index 000000000..08a24df73 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/custom_session.py @@ -0,0 +1,6 @@ +from rest_framework.authentication import SessionAuthentication, BasicAuthentication + +class CsrfExemptSessionAuthentication(SessionAuthentication): + + def enforce_csrf(self, request): + return # To not perform the csrf check previously happening \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/migrations/__init__.py b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/models.py b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/models.py new file mode 100644 index 000000000..71a836239 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/tests.py b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/urls.py b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/urls.py new file mode 100644 index 000000000..2ecf4c2ca --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views +from myapp.views import MyView + +urlpatterns = [ + path('', MyView.as_view(), name='my-view'), +] \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/views.py b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/views.py new file mode 100644 index 000000000..53a3fdba7 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/django_sample/myapp/views.py @@ -0,0 +1,19 @@ +from django.shortcuts import render +from django.http import HttpResponse + +from rest_framework.views import APIView +from django.views.decorators.csrf import csrf_exempt +from .custom_session import CsrfExemptSessionAuthentication +from rest_framework.authentication import SessionAuthentication, BasicAuthentication +from botbuilder.applicationinsights import ApplicationInsightsTelemetryClient + +instrumentation_key = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' +telemetry = ApplicationInsightsTelemetryClient(instrumentation_key) + +class MyView(APIView): + authentication_classes = (CsrfExemptSessionAuthentication, BasicAuthentication) + @csrf_exempt + def post(self, request, *args, **kwargs): + telemetry.track_event("DjangoHello") + telemetry.flush() + return HttpResponse("YOU POSTED DATA.") diff --git a/libraries/botbuilder-applicationinsights/samples/flask_sample.py b/libraries/botbuilder-applicationinsights/samples/flask_sample.py new file mode 100644 index 000000000..9f9f50ce5 --- /dev/null +++ b/libraries/botbuilder-applicationinsights/samples/flask_sample.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from flask import Flask +from flask import request +from botbuilder.applicationinsights import ApplicationInsightsTelemetryClient + +# Instantiate the Flask application +app = Flask(__name__) + +# Register App Insights to pull telemetry +instrumentation_key = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' +app.config['APPINSIGHTS_INSTRUMENTATIONKEY'] = instrumentation_key + +telemetry = ApplicationInsightsTelemetryClient(instrumentation_key) + +# define a simple route +@app.route('/', methods=['POST']) +def hello_world(): + # Use Bot's Telemetry Client which replaces session_id, user_id and adds bot-specific ID's + telemetry.track_event("Hello World") + telemetry.flush() + return 'Hello World!' + +# run the application +if __name__ == '__main__': + app.run() \ No newline at end of file diff --git a/libraries/botbuilder-applicationinsights/setup.py b/libraries/botbuilder-applicationinsights/setup.py index 4349f16a9..c68f736d5 100644 --- a/libraries/botbuilder-applicationinsights/setup.py +++ b/libraries/botbuilder-applicationinsights/setup.py @@ -5,11 +5,17 @@ from setuptools import setup REQUIRES = [ - 'aiounittest>=1.1.0', - 'applicationinsights >=0.11.8', + 'applicationinsights >=0.11.9', 'botbuilder-schema>=4.0.0.a6', 'botframework-connector>=4.0.0.a6', - 'botbuilder-core>=4.0.0.a6'] + 'botbuilder-core>=4.0.0.a6' + ] +TESTS_REQUIRES = [ + 'aiounittest>=1.1.0', + 'django>=2.2', # For samples + 'djangorestframework>=3.9.2', # For samples + 'flask>-1.0.2' # For samples + ] root = os.path.abspath(os.path.dirname(__file__)) @@ -28,7 +34,8 @@ long_description=package_info['__summary__'], license=package_info['__license__'], packages=['botbuilder.applicationinsights'], - install_requires=REQUIRES, + install_requires=REQUIRES + TESTS_REQUIRES, + tests_require=TESTS_REQUIRES, include_package_data=True, classifiers=[ 'Programming Language :: Python :: 3.6', diff --git a/libraries/botbuilder-applicationinsights/tests/__init__.py b/libraries/botbuilder-applicationinsights/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libraries/botbuilder-dialogs/setup.py b/libraries/botbuilder-dialogs/setup.py index b987d538f..6ffc63e05 100644 --- a/libraries/botbuilder-dialogs/setup.py +++ b/libraries/botbuilder-dialogs/setup.py @@ -5,11 +5,14 @@ from setuptools import setup REQUIRES = [ - 'aiounittest>=1.1.0', 'botbuilder-schema>=4.0.0.a6', 'botframework-connector>=4.0.0.a6', 'botbuilder-core>=4.0.0.a6'] +TEST_REQUIRES = [ + 'aiounittest>=1.1.0' +] + root = os.path.abspath(os.path.dirname(__file__)) with open(os.path.join(root, 'botbuilder', 'dialogs', 'about.py')) as f: @@ -27,7 +30,8 @@ long_description=package_info['__summary__'], license=package_info['__license__'], packages=['botbuilder.dialogs'], - install_requires=REQUIRES, + install_requires=REQUIRES + TEST_REQUIRES, + tests_require=TEST_REQUIRES, include_package_data=True, classifiers=[ 'Programming Language :: Python :: 3.6', From e581ebd6cab8578ce18c66be792a919345d71926 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Sun, 28 Apr 2019 13:48:37 -0700 Subject: [PATCH 61/73] fixed spacing in files --- libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py | 3 ++- .../botbuilder/ai/qna/qna_telemetry_constants.py | 2 +- libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py | 10 ---------- .../botbuilder/ai/qna/qnamaker_endpoint.py | 2 +- .../botbuilder/ai/qna/qnamaker_options.py | 1 + .../botbuilder/ai/qna/qnamaker_telemetry_client.py | 2 +- 6 files changed, 6 insertions(+), 14 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py index 58140f73f..b495b0006 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/__init__.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from .metadata import Metadata from .query_result import QueryResult from .qnamaker import QnAMaker @@ -18,4 +19,4 @@ 'QnAMakerTelemetryClient', 'QnAMakerTraceInfo', 'QnATelemetryConstants', -] \ No newline at end of file +] diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qna_telemetry_constants.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qna_telemetry_constants.py index 5d37cf838..67f8536bd 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qna_telemetry_constants.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qna_telemetry_constants.py @@ -20,4 +20,4 @@ class QnATelemetryConstants(str, Enum): matched_question_property = 'matchedQuestion' question_id_property = 'questionId' score_metric = 'score' - username_property = 'username' \ No newline at end of file + username_property = 'username' diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index 22b6a62c8..e62d9b336 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -15,16 +15,6 @@ from .qna_telemetry_constants import QnATelemetryConstants from .qnamaker_trace_info import QnAMakerTraceInfo -# from . import ( -# Metadata, -# QueryResult, -# QnAMakerEndpoint, -# QnAMakerOptions, -# QnAMakerTelemetryClient, -# QnATelemetryConstants, -# QnAMakerTraceInfo -# ) - QNAMAKER_TRACE_NAME = 'QnAMaker' QNAMAKER_TRACE_LABEL = 'QnAMaker Trace' QNAMAKER_TRACE_TYPE = 'https://www.qnamaker.ai/schemas/trace' diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py index cdd7a8546..3d85a9404 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_endpoint.py @@ -15,4 +15,4 @@ def __init__(self, knowledge_base_id: str, endpoint_key: str, host: str): self.knowledge_base_id = knowledge_base_id self.endpoint_key = endpoint_key - self.host = host \ No newline at end of file + self.host = host diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py index 0fdeb075e..7ecf01d06 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from .metadata import Metadata # figure out if 300 milliseconds is ok for python requests library...or 100000 diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_telemetry_client.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_telemetry_client.py index 9dccf2f55..ca0444eab 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_telemetry_client.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_telemetry_client.py @@ -23,4 +23,4 @@ def get_answers( telemetry_properties: Dict[str,str] = None, telemetry_metrics: Dict[str, float] = None ): - raise NotImplementedError('QnAMakerTelemetryClient.get_answers(): is not implemented.') \ No newline at end of file + raise NotImplementedError('QnAMakerTelemetryClient.get_answers(): is not implemented.') From 395f95b848e71e5606ed0a3f476af16eddb2b55c Mon Sep 17 00:00:00 2001 From: congysu Date: Sun, 28 Apr 2019 14:38:58 -0700 Subject: [PATCH 62/73] add user agent * add user agent * add test for user agent --- .../botbuilder-ai/botbuilder/ai/__init__.py | 6 + .../botbuilder/ai/luis/luis_recognizer.py | 1 + .../botbuilder/ai/luis/luis_util.py | 12 ++ .../tests/luis/luis_recognizer_test.py | 116 ++++++++---------- .../botbuilder-ai/tests/luis/null_adapter.py | 24 ++++ 5 files changed, 97 insertions(+), 62 deletions(-) create mode 100644 libraries/botbuilder-ai/tests/luis/null_adapter.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/__init__.py b/libraries/botbuilder-ai/botbuilder/ai/__init__.py index e69de29bb..f36291318 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/__init__.py +++ b/libraries/botbuilder-ai/botbuilder/ai/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .about import __title__, __version__ + +__all__ = ["__title__", "__version__"] diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index a6cc2a499..be399de14 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -72,6 +72,7 @@ def __init__( credentials = CognitiveServicesCredentials(self._application.endpoint_key) self._runtime = LUISRuntimeClient(self._application.endpoint, credentials) + self._runtime.config.add_user_agent(LuisUtil.get_user_agent()) @property def log_personal_information(self) -> bool: diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py index befcfc1d1..c8bd71e22 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_util.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import platform from collections import OrderedDict from typing import Dict, List, Set, Union @@ -10,6 +11,7 @@ LuisResult, ) +from .. import __title__, __version__ from . import IntentScore, RecognizerResult @@ -283,3 +285,13 @@ def add_properties(luis: LuisResult, result: RecognizerResult) -> None: "label": luis.sentiment_analysis.label, "score": luis.sentiment_analysis.score, } + + @staticmethod + def get_user_agent(): + package_user_agent = f"{__title__}/{__version__}" + uname = platform.uname() + os_version = f"{uname.machine}-{uname.system}-{uname.version}" + py_version = f"Python,Version={platform.python_version()}" + platform_user_agent = f"({os_version}; {py_version})" + user_agent = f"{package_user_agent} {platform_user_agent}" + return user_agent diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index be3f262cd..5d964d06d 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -4,9 +4,14 @@ import json import unittest from os import path +from typing import Tuple from unittest.mock import Mock, patch import requests +from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient +from azure.cognitiveservices.language.luis.runtime.luis_runtime_client import ( + LUISRuntimeClientConfiguration, +) from msrest import Deserializer from requests.models import Response @@ -18,7 +23,7 @@ RecognizerResult, TopIntent, ) -from botbuilder.core import TurnContext +from botbuilder.core import BotAdapter, TurnContext from botbuilder.core.adapters import TestAdapter from botbuilder.schema import ( Activity, @@ -27,6 +32,8 @@ ConversationAccount, ) +from .null_adapter import NullAdapter + class LuisRecognizerTest(unittest.TestCase): _luisAppId: str = "b31aeaf3-3511-495b-a07f-571fc873214b" @@ -88,47 +95,10 @@ def test_luis_recognizer_none_luis_app_arg(self): def test_single_intent_simply_entity(self): utterance: str = "My name is Emad" - response_str: str = """{ - "query": "my name is Emad", - "topScoringIntent": { - "intent": "SpecifyName", - "score": 0.8785189 - }, - "intents": [ - { - "intent": "SpecifyName", - "score": 0.8785189 - } - ], - "entities": [ - { - "entity": "emad", - "type": "Name", - "startIndex": 11, - "endIndex": 14, - "score": 0.8446753 - } - ] - }""" - response_json = json.loads(response_str) + response_path: str = "SingleIntent_SimplyEntity.json" + + _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) - my_app = LuisApplication( - LuisRecognizerTest._luisAppId, - LuisRecognizerTest._subscriptionKey, - endpoint="", - ) - recognizer = LuisRecognizer(my_app, prediction_options=None) - context = LuisRecognizerTest._get_context(utterance) - response = Mock(spec=Response) - response.status_code = 200 - response.headers = {} - response.reason = "" - with patch("requests.Session.send", return_value=response): - with patch( - "msrest.serialization.Deserializer._unpack_content", - return_value=response_json, - ): - result = recognizer.recognize(context) self.assertIsNotNone(result) self.assertIsNone(result.altered_text) self.assertEqual(utterance, result.text) @@ -149,7 +119,7 @@ def test_null_utterance(self): utterance: str = None response_path: str = "SingleIntent_SimplyEntity.json" # The path is irrelevant in this case - result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) self.assertIsNotNone(result) self.assertIsNone(result.altered_text) @@ -165,7 +135,7 @@ def test_multiple_intents_prebuilt_entity(self): utterance: str = "Please deliver February 2nd 2001" response_path: str = "MultipleIntents_PrebuiltEntity.json" - result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) self.assertIsNotNone(result) self.assertEqual(utterance, result.text) @@ -202,7 +172,7 @@ def test_multiple_intents_prebuilt_entities_with_multi_values(self): utterance: str = "Please deliver February 2nd 2001 in room 201" response_path: str = "MultipleIntents_PrebuiltEntitiesWithMultiValues.json" - result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) self.assertIsNotNone(result) self.assertIsNotNone(result.text) @@ -221,7 +191,7 @@ def test_multiple_intents_list_entity_with_single_value(self): utterance: str = "I want to travel on united" response_path: str = "MultipleIntents_ListEntityWithSingleValue.json" - result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) self.assertIsNotNone(result) self.assertIsNotNone(result.text) @@ -241,7 +211,7 @@ def test_multiple_intents_list_entity_with_multi_values(self): utterance: str = "I want to travel on DL" response_path: str = "MultipleIntents_ListEntityWithMultiValues.json" - result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) self.assertIsNotNone(result) self.assertIsNotNone(result.text) @@ -263,7 +233,7 @@ def test_multiple_intents_composite_entity_model(self): utterance: str = "Please deliver it to 98033 WA" response_path: str = "MultipleIntents_CompositeEntityModel.json" - result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) self.assertIsNotNone(result) self.assertIsNotNone(result.text) @@ -313,7 +283,7 @@ def test_multiple_date_time_entities(self): utterance: str = "Book a table on Friday or tomorrow at 5 or tomorrow at 4" response_path: str = "MultipleDateTimeEntities.json" - result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) self.assertIsNotNone(result.entities["datetime"]) self.assertEqual(3, len(result.entities["datetime"])) @@ -332,7 +302,7 @@ def test_v1_datetime_resolution(self): utterance: str = "at 4" response_path: str = "V1DatetimeResolution.json" - result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) self.assertIsNotNone(result.entities["datetime_time"]) self.assertEqual(1, len(result.entities["datetime_time"])) @@ -367,20 +337,33 @@ def test_top_intent_returns_top_intent_if_score_equals_min_score(self): ) self.assertEqual(default_intent, "Greeting") + def test_user_agent_contains_product_version(self): + utterance: str = "please book from May 5 to June 6" + response_path: str = "MultipleDateTimeEntities.json" # it doesn't matter to use which file. + + recognizer, _ = LuisRecognizerTest._get_recognizer_result( + utterance, response_path, bot_adapter=NullAdapter() + ) + + runtime: LUISRuntimeClient = recognizer._runtime + config: LUISRuntimeClientConfiguration = runtime.config + user_agent = config.user_agent + + # Verify we didn't unintentionally stamp on the user-agent from the client. + self.assertTrue("azure-cognitiveservices-language-luis" in user_agent) + + # And that we added the bot.builder package details. + self.assertTrue("botbuilder-ai/4" in user_agent) + def assert_score(self, score: float) -> None: self.assertTrue(score >= 0) self.assertTrue(score <= 1) @classmethod def _get_recognizer_result( - cls, utterance: str, response_file: str - ) -> RecognizerResult: - 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 f: - response_str = f.read() - response_json = json.loads(response_str) + cls, utterance: str, response_file: str, bot_adapter: BotAdapter = TestAdapter() + ) -> Tuple[LuisRecognizer, RecognizerResult]: + response_json = LuisRecognizerTest._get_json_for_file(response_file) my_app = LuisApplication( LuisRecognizerTest._luisAppId, @@ -388,7 +371,7 @@ def _get_recognizer_result( endpoint="", ) recognizer = LuisRecognizer(my_app, prediction_options=None) - context = LuisRecognizerTest._get_context(utterance) + context = LuisRecognizerTest._get_context(utterance, bot_adapter) response = Mock(spec=Response) response.status_code = 200 response.headers = {} @@ -399,7 +382,17 @@ def _get_recognizer_result( return_value=response_json, ): result = recognizer.recognize(context) - return result + return recognizer, result + + @classmethod + def _get_json_for_file(cls, 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 f: + response_str = f.read() + response_json = json.loads(response_str) + return response_json @classmethod def _get_luis_recognizer( @@ -409,8 +402,7 @@ def _get_luis_recognizer( return LuisRecognizer(luis_app, options, verbose) @staticmethod - def _get_context(utterance: str) -> TurnContext: - test_adapter = TestAdapter() + def _get_context(utterance: str, bot_adapter: BotAdapter) -> TurnContext: activity = Activity( type=ActivityTypes.message, text=utterance, @@ -418,4 +410,4 @@ def _get_context(utterance: str) -> TurnContext: recipient=ChannelAccount(), from_property=ChannelAccount(), ) - return TurnContext(test_adapter, activity) + return TurnContext(bot_adapter, activity) diff --git a/libraries/botbuilder-ai/tests/luis/null_adapter.py b/libraries/botbuilder-ai/tests/luis/null_adapter.py new file mode 100644 index 000000000..d00a25271 --- /dev/null +++ b/libraries/botbuilder-ai/tests/luis/null_adapter.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.core import BotAdapter, TurnContext +from botbuilder.schema import Activity, ConversationReference, ResourceResponse + + +class NullAdapter(BotAdapter): + """ + This is a BotAdapter that does nothing on the Send operation, equivalent to piping to /dev/null. + """ + + def send_activities(self, context: TurnContext, activities: List[Activity]): + return [ResourceResponse()] + + async def update_activity(self, context: TurnContext, activity: Activity): + raise NotImplementedError() + + async def delete_activity( + self, context: TurnContext, reference: ConversationReference + ): + raise NotImplementedError() From f3cac967d750dfcc5ad95274e53e65c077aa1699 Mon Sep 17 00:00:00 2001 From: congysu Date: Sun, 28 Apr 2019 18:28:03 -0700 Subject: [PATCH 63/73] add connection timeout * add connection timeout * add test for connection timeout --- .../botbuilder/ai/luis/luis_recognizer.py | 1 + .../tests/luis/luis_recognizer_test.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index be399de14..87229e4be 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -73,6 +73,7 @@ def __init__( credentials = CognitiveServicesCredentials(self._application.endpoint_key) self._runtime = LUISRuntimeClient(self._application.endpoint, credentials) self._runtime.config.add_user_agent(LuisUtil.get_user_agent()) + self._runtime.config.connection.timeout = self._options.timeout // 1000 @property def log_personal_information(self) -> bool: diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index 5d964d06d..c42df4724 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -59,6 +59,17 @@ def test_luis_recognizer_construction(self): self.assertEqual("048ec46dc58e495482b0c447cfdbd291", app.endpoint_key) self.assertEqual("https://westus.api.cognitive.microsoft.com", app.endpoint) + def test_luis_recognizer_timeout(self): + endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q=" + expected_timeout = 300 + options_with_timeout = LuisPredictionOptions(timeout=expected_timeout * 1000) + + recognizer_with_timeout = LuisRecognizer(endpoint, options_with_timeout) + + self.assertEqual( + expected_timeout, recognizer_with_timeout._runtime.config.connection.timeout + ) + def test_none_endpoint(self): # Arrange my_app = LuisApplication( @@ -355,6 +366,21 @@ def test_user_agent_contains_product_version(self): # And that we added the bot.builder package details. self.assertTrue("botbuilder-ai/4" in user_agent) + def test_telemetry_construction(self): + # Arrange + # Note this is NOT a real LUIS application ID nor a real LUIS subscription-key + # theses are GUIDs edited to look right to the parsing and validation code. + endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q=" + + # Act + recognizer = LuisRecognizer(endpoint) + + # Assert + app = recognizer._application + self.assertEqual("b31aeaf3-3511-495b-a07f-571fc873214b", app.application_id) + self.assertEqual("048ec46dc58e495482b0c447cfdbd291", app.endpoint_key) + self.assertEqual("https://westus.api.cognitive.microsoft.com", app.endpoint) + def assert_score(self, score: float) -> None: self.assertTrue(score >= 0) self.assertTrue(score <= 1) From 00d0bd2e506e0166c185871649aaae92fca038e9 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Mon, 29 Apr 2019 06:09:40 -0700 Subject: [PATCH 64/73] ctor tests --- .../botbuilder/ai/qna/qnamaker.py | 28 +++++++++---------- .../botbuilder/ai/qna/qnamaker_options.py | 8 +++++- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py index e62d9b336..2d10983bc 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker.py @@ -45,7 +45,7 @@ def __init__( self._is_legacy_protocol: bool = self._endpoint.host.endswith('v3.0') self._options: QnAMakerOptions = options or QnAMakerOptions() - self.validate_options(self._options) + self._validate_options(self._options) self._telemetry_client = telemetry_client or NullTelemetryClient() self._log_personal_information = log_personal_information or False @@ -187,16 +187,16 @@ async def get_answers( """ - hydrated_options = self.hydrate_options(options) - self.validate_options(hydrated_options) + hydrated_options = self._hydrate_options(options) + self._validate_options(hydrated_options) - result = self.query_qna_service(context.activity, hydrated_options) + result = self._query_qna_service(context.activity, hydrated_options) - await self.emit_trace_info(context, result, hydrated_options) + await self._emit_trace_info(context, result, hydrated_options) return result - def validate_options(self, options: QnAMakerOptions): + def _validate_options(self, options: QnAMakerOptions): if not options.score_threshold: options.score_threshold = 0.3 @@ -210,9 +210,9 @@ def validate_options(self, options: QnAMakerOptions): raise ValueError('QnAMakerOptions.top should be an integer greater than 0') if not options.strict_filters: - options.strict_filters = [Metadata] + options.strict_filters = [] - def hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: + def _hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: """ Combines QnAMakerOptions passed into the QnAMaker constructor with the options passed as arguments into get_answers(). @@ -238,7 +238,7 @@ def hydrate_options(self, query_options: QnAMakerOptions) -> QnAMakerOptions: return hydrated_options - def query_qna_service(self, message_activity: Activity, options: QnAMakerOptions) -> [QueryResult]: + def _query_qna_service(self, message_activity: Activity, options: QnAMakerOptions) -> [QueryResult]: url = f'{ self._endpoint.host }/knowledgebases/{ self._endpoint.knowledge_base_id }/generateAnswer' question = { @@ -250,15 +250,15 @@ def query_qna_service(self, message_activity: Activity, options: QnAMakerOptions serialized_content = json.dumps(question) - headers = self.get_headers() + headers = self._get_headers() response = requests.post(url, data=serialized_content, headers=headers) - result = self.format_qna_result(response, options) + result = self._format_qna_result(response, options) return result - async def emit_trace_info(self, turn_context: TurnContext, result: [QueryResult], options: QnAMakerOptions): + async def _emit_trace_info(self, turn_context: TurnContext, result: [QueryResult], options: QnAMakerOptions): trace_info = QnAMakerTraceInfo( message = turn_context.activity, query_results = result, @@ -278,7 +278,7 @@ async def emit_trace_info(self, turn_context: TurnContext, result: [QueryResult] await turn_context.send_activity(trace_activity) - def format_qna_result(self, qna_result: requests.Response, options: QnAMakerOptions) -> [QueryResult]: + def _format_qna_result(self, qna_result: requests.Response, options: QnAMakerOptions) -> [QueryResult]: result = qna_result.json() answers_within_threshold = [ @@ -297,7 +297,7 @@ def format_qna_result(self, qna_result: requests.Response, options: QnAMakerOpti return answers_as_query_results - def get_headers(self): + def _get_headers(self): headers = { 'Content-Type': 'application/json' } if self._is_legacy_protocol: diff --git a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py index 7ecf01d06..d41dedbf8 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/qna/qnamaker_options.py @@ -5,7 +5,13 @@ # figure out if 300 milliseconds is ok for python requests library...or 100000 class QnAMakerOptions: - def __init__(self, score_threshold: float = 0.0, timeout: int = 0, top: int = 0, strict_filters: [Metadata] = []): + def __init__( + self, + score_threshold: float = 0.0, + timeout: int = 0, + top: int = 0, + strict_filters: [Metadata] = [] + ): self.score_threshold = score_threshold self.timeout = timeout self.top = top From 59b5cbc70eacee83b88d8fcc98e6837c74f37db4 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Mon, 29 Apr 2019 10:36:45 -0700 Subject: [PATCH 65/73] added mocks to test QnAMaker.get_answers() --- libraries/botbuilder-ai/setup.py | 1 + .../qna/test_data/AnswerWithOptions.json | 50 +++ libraries/botbuilder-ai/tests/qna/test_qna.py | 284 ++++++++++++++++-- 3 files changed, 302 insertions(+), 33 deletions(-) create mode 100644 libraries/botbuilder-ai/tests/qna/test_data/AnswerWithOptions.json diff --git a/libraries/botbuilder-ai/setup.py b/libraries/botbuilder-ai/setup.py index 1b4848f7d..d31099042 100644 --- a/libraries/botbuilder-ai/setup.py +++ b/libraries/botbuilder-ai/setup.py @@ -11,6 +11,7 @@ "botbuilder-core>=4.0.0.a6", ] + root = os.path.abspath(os.path.dirname(__file__)) with open(os.path.join(root, "botbuilder", "ai", "about.py")) as f: diff --git a/libraries/botbuilder-ai/tests/qna/test_data/AnswerWithOptions.json b/libraries/botbuilder-ai/tests/qna/test_data/AnswerWithOptions.json new file mode 100644 index 000000000..25e908be1 --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/AnswerWithOptions.json @@ -0,0 +1,50 @@ +{ + "answers": [ + { + "questions": [ + "up" + ], + "answer": "is a movie", + "score": 100, + "id": 3, + "source": "Editorial", + "metadata": [ + { + "name": "movie", + "value": "disney" + } + ] + }, + { + "questions": [ + "up" + ], + "answer": "2nd answer", + "score": 100, + "id": 4, + "source": "Editorial", + "metadata": [ + { + "name": "movie", + "value": "disney" + } + ] + }, + { + "questions": [ + "up" + ], + "answer": "3rd answer", + "score": 100, + "id": 5, + "source": "Editorial", + "metadata": [ + { + "name": "movie", + "value": "disney" + } + ] + } + ], + "debugInfo": null +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index 7cf19dc44..98fac3365 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -2,42 +2,260 @@ # Licensed under the MIT License. import json -import aiounittest +import aiounittest, unittest, requests +from msrest import Deserializer +from os import path +from requests.models import Response from typing import List, Tuple from uuid import uuid4 -from botbuilder.schema import Activity, ChannelAccount, ResourceResponse, ConversationAccount +from unittest.mock import Mock, patch, MagicMock +from asyncio import Future + + + +from botbuilder.ai.qna import Metadata, QnAMakerEndpoint, QnAMaker, QnAMakerOptions, QnATelemetryConstants, QueryResult from botbuilder.core import BotAdapter, BotTelemetryClient, NullTelemetryClient, TurnContext -from botbuilder.ai.qna import QnAMakerEndpoint, QnAMaker, QnAMakerOptions, QnATelemetryConstants - - -# DELETE YO -ACTIVITY = Activity(id='1234', - type='message', - text='up', - from_property=ChannelAccount(id='user', name='User Name'), - recipient=ChannelAccount(id='bot', name='Bot Name'), - conversation=ConversationAccount(id='convo', name='Convo Name'), - channel_id='UnitTest', - service_url='https://example.org' - ) - -class SimpleAdapter(BotAdapter): - async def send_activities(self, context, activities): - responses = [] - for (idx, activity) in enumerate(activities): - responses.append(ResourceResponse(id='5678')) - return responses - - async def update_activity(self, context, activity): - assert context is not None - assert activity is not None - - async def delete_activity(self, context, reference): - assert context is not None - assert reference is not None - assert reference.activity_id == '1234' +from botbuilder.core.adapters import TestAdapter +from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, ResourceResponse, ConversationAccount + +# from botbuilder.ai.qna import ( +# Metadata, +# QnAMakerEndpoint, +# QnAMaker, +# QnAMakerOptions, +# QnATelemetryConstants, +# QueryResult +# ) +# from botbuilder.core import (BotAdapter, +# BotTelemetryClient, +# NullTelemetryClient, +# TurnContext +# ) +# from botbuilder.core.adapters import TestAdapter +# from botbuilder.schema import (Activity, +# ActivityTypes, +# ChannelAccount, +# ResourceResponse, +# ConversationAccount +# ) + class QnaApplicationTest(aiounittest.AsyncTestCase): + _knowledge_base_id: str = 'a090f9f3-2f8e-41d1-a581-4f7a49269a0c' + _endpoint_key: str = '4a439d5b-163b-47c3-b1d1-168cc0db5608' + _host: str = 'https://ashleyNlpBot1-qnahost.azurewebsites.net/qnamaker' + + tests_endpoint = QnAMakerEndpoint(_knowledge_base_id, _endpoint_key, _host) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # self._mocked_results: QueryResult( + + # ) + + def test_qnamaker_construction(self): + # Arrange + endpoint = self.tests_endpoint + + # Act + qna = QnAMaker(endpoint) + endpoint = qna._endpoint + + # Assert + self.assertEqual('a090f9f3-2f8e-41d1-a581-4f7a49269a0c', endpoint.knowledge_base_id) + self.assertEqual('4a439d5b-163b-47c3-b1d1-168cc0db5608', endpoint.endpoint_key) + self.assertEqual('https://ashleyNlpBot1-qnahost.azurewebsites.net/qnamaker', endpoint.host) + + + def test_endpoint_with_empty_kbid(self): + empty_kbid = '' + + with self.assertRaises(TypeError): + QnAMakerEndpoint( + empty_kbid, + self._endpoint_key, + self._host + ) + + def test_endpoint_with_empty_endpoint_key(self): + empty_endpoint_key = '' + + with self.assertRaises(TypeError): + QnAMakerEndpoint( + self._knowledge_base_id, + empty_endpoint_key, + self._host + ) + + def test_endpoint_with_emptyhost(self): + with self.assertRaises(TypeError): + QnAMakerEndpoint( + self._knowledge_base_id, + self._endpoint_key, + '' + ) + + def test_qnamaker_with_none_endpoint(self): + with self.assertRaises(TypeError): + QnAMaker(None) + + def test_v2_legacy_endpoint(self): + v2_hostname = 'https://westus.api.cognitive.microsoft.com/qnamaker/v2.0' + + v2_legacy_endpoint = QnAMakerEndpoint( + self._knowledge_base_id, + self._endpoint_key, + v2_hostname + ) + + with self.assertRaises(ValueError): + QnAMaker(v2_legacy_endpoint) + + def test_legacy_protocol(self): + v3_hostname = 'https://westus.api.cognitive.microsoft.com/qnamaker/v3.0' + v3_legacy_endpoint = QnAMakerEndpoint( + self._knowledge_base_id, + self._endpoint_key, + v3_hostname + ) + legacy_qna = QnAMaker(v3_legacy_endpoint) + is_legacy = True + + v4_hostname = 'https://UpdatedNonLegacyQnaHostName.azurewebsites.net/qnamaker' + nonlegacy_endpoint = QnAMakerEndpoint( + self._knowledge_base_id, + self._endpoint_key, + v4_hostname + ) + v4_qna = QnAMaker(nonlegacy_endpoint) + + self.assertEqual(is_legacy, legacy_qna._is_legacy_protocol) + self.assertNotEqual(is_legacy, v4_qna._is_legacy_protocol) + + def test_set_default_options_with_no_options_arg(self): + qna_without_options = QnAMaker(self.tests_endpoint) + + options = qna_without_options._options + + default_threshold = 0.3 + default_top = 1 + default_strict_filters = [] + + self.assertEqual(default_threshold, options.score_threshold) + self.assertEqual(default_top, options.top) + self.assertEqual(default_strict_filters, options.strict_filters) + + def test_options_passed_to_ctor(self): + options = QnAMakerOptions( + score_threshold=0.8, + timeout=9000, + top=5, + strict_filters=[Metadata('movie', 'disney')] + ) + + qna_with_options = QnAMaker(self.tests_endpoint, options) + actual_options = qna_with_options._options + + expected_threshold = 0.8 + expected_timeout = 9000 + expected_top = 5 + expected_strict_filters = [Metadata('movie', 'disney')] + + self.assertEqual(expected_threshold, actual_options.score_threshold) + self.assertEqual(expected_timeout, actual_options.timeout) + self.assertEqual(expected_top, actual_options.top) + self.assertEqual(expected_strict_filters[0].name, actual_options.strict_filters[0].name) + self.assertEqual(expected_strict_filters[0].value, actual_options.strict_filters[0].value) + + + async def test_returns_answer_using_options(self): + # Arrange + question: str = 'up' + response_path: str = 'AnswerWithOptions.json' + options = QnAMakerOptions( + score_threshold = 0.8, + top = 5, + strict_filters = [{ + 'name': 'movie', + 'value': 'disney' + }] + ) + + # Act + result = await QnaApplicationTest._get_service_result( + question, + response_path, + options=options + ) + + first_answer = result['answers'][0] + has_at_least_1_ans = True + first_metadata = first_answer['metadata'][0] + + # Assert + self.assertIsNotNone(result) + self.assertEqual(has_at_least_1_ans, len(result['answers']) >= 1 and len(result['answers']) <= options.top) + self.assertTrue(question in first_answer['questions']) + self.assertTrue(first_answer['answer']) + self.assertEqual('is a movie', first_answer['answer']) + self.assertTrue(first_answer['score'] >= options.score_threshold) + self.assertEqual('movie', first_metadata['name']) + self.assertEqual('disney', first_metadata['value']) + + @classmethod + async def _get_service_result( + cls, + utterance: str, + response_file: str, + bot_adapter: BotAdapter = TestAdapter(), + options: QnAMakerOptions = None + ) -> [QueryResult]: + response_json = QnaApplicationTest._get_json_for_file(response_file) + + qna = QnAMaker(QnaApplicationTest.tests_endpoint) + context = QnaApplicationTest._get_context(utterance, bot_adapter) + response = Mock(spec=Response) + response.status_code = 200 + response.headers = {} + response.reason = '' + + with patch('requests.post', return_value=response): + with patch('botbuilder.ai.qna.QnAMaker._format_qna_result', return_value=response_json): + result = await qna.get_answers(context, options) + + return result + + @classmethod + def _get_json_for_file(cls, 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 f: + response_str = f.read() + response_json = json.loads(response_str) + + return response_json + + @staticmethod + def _get_context(utterance: str, bot_adapter: BotAdapter) -> TurnContext: + test_adapter = TestAdapter() + activity = Activity( + type = ActivityTypes.message, + text = utterance, + conversation = ConversationAccount(), + recipient = ChannelAccount(), + from_property = ChannelAccount(), + ) + + return TurnContext(test_adapter, activity) + + + + + + + + + + - async def test_initial_test(self): - pass \ No newline at end of file From 19762ce34a542989b73a2141828e7ef9bd5a6a91 Mon Sep 17 00:00:00 2001 From: Ashley Ho <35248895+Zerryth@users.noreply.github.com> Date: Mon, 29 Apr 2019 11:12:12 -0700 Subject: [PATCH 66/73] test: returns answer w/o options --- .../tests/qna/test_data/ReturnsAnswer.json | 14 +++++++ libraries/botbuilder-ai/tests/qna/test_qna.py | 42 +++++++++---------- 2 files changed, 34 insertions(+), 22 deletions(-) create mode 100644 libraries/botbuilder-ai/tests/qna/test_data/ReturnsAnswer.json diff --git a/libraries/botbuilder-ai/tests/qna/test_data/ReturnsAnswer.json b/libraries/botbuilder-ai/tests/qna/test_data/ReturnsAnswer.json new file mode 100644 index 000000000..98ad181ee --- /dev/null +++ b/libraries/botbuilder-ai/tests/qna/test_data/ReturnsAnswer.json @@ -0,0 +1,14 @@ +{ + "answers": [ + { + "questions": [ + "how do I clean the stove?" + ], + "answer": "BaseCamp: You can use a damp rag to clean around the Power Pack", + "score": 100, + "id": 5, + "source": "Editorial", + "metadata": [] + } + ] +} \ No newline at end of file diff --git a/libraries/botbuilder-ai/tests/qna/test_qna.py b/libraries/botbuilder-ai/tests/qna/test_qna.py index 98fac3365..6748ae2b1 100644 --- a/libraries/botbuilder-ai/tests/qna/test_qna.py +++ b/libraries/botbuilder-ai/tests/qna/test_qna.py @@ -18,27 +18,6 @@ from botbuilder.core.adapters import TestAdapter from botbuilder.schema import Activity, ActivityTypes, ChannelAccount, ResourceResponse, ConversationAccount -# from botbuilder.ai.qna import ( -# Metadata, -# QnAMakerEndpoint, -# QnAMaker, -# QnAMakerOptions, -# QnATelemetryConstants, -# QueryResult -# ) -# from botbuilder.core import (BotAdapter, -# BotTelemetryClient, -# NullTelemetryClient, -# TurnContext -# ) -# from botbuilder.core.adapters import TestAdapter -# from botbuilder.schema import (Activity, -# ActivityTypes, -# ChannelAccount, -# ResourceResponse, -# ConversationAccount -# ) - class QnaApplicationTest(aiounittest.AsyncTestCase): _knowledge_base_id: str = 'a090f9f3-2f8e-41d1-a581-4f7a49269a0c' @@ -167,6 +146,25 @@ def test_options_passed_to_ctor(self): self.assertEqual(expected_strict_filters[0].name, actual_options.strict_filters[0].name) self.assertEqual(expected_strict_filters[0].value, actual_options.strict_filters[0].value) + async def test_returns_answer(self): + # Arrange + question: str = 'how do I clean the stove?' + response_path: str = 'ReturnsAnswer.json' + + # Act + result = await QnaApplicationTest._get_service_result( + question, + response_path + ) + + first_answer = result['answers'][0] + + #Assert + self.assertIsNotNone(result) + self.assertEqual(1, len(result['answers'])) + self.assertTrue(question in first_answer['questions']) + self.assertEqual('BaseCamp: You can use a damp rag to clean around the Power Pack', first_answer['answer']) + async def test_returns_answer_using_options(self): # Arrange @@ -209,7 +207,7 @@ async def _get_service_result( response_file: str, bot_adapter: BotAdapter = TestAdapter(), options: QnAMakerOptions = None - ) -> [QueryResult]: + ) -> [dict]: response_json = QnaApplicationTest._get_json_for_file(response_file) qna = QnAMaker(QnaApplicationTest.tests_endpoint) From 39cd5591b70020651627d0b3e33bf638261b1aa6 Mon Sep 17 00:00:00 2001 From: congysu Date: Mon, 29 Apr 2019 10:35:20 -0700 Subject: [PATCH 67/73] update comments for luis prediction options and update tests --- .../ai/luis/luis_prediction_options.py | 40 +++++++++---------- .../tests/luis/luis_recognizer_test.py | 19 +++++---- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py index c6f91a724..e33bf62ec 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_prediction_options.py @@ -23,7 +23,7 @@ def __init__(self, timeout: float = 100000): @property def bing_spell_check_subscription_key(self) -> str: - """Gets or sets the Bing Spell Check subscription key. + """Gets the Bing Spell Check subscription key. :return: The Bing Spell Check subscription key. :rtype: str @@ -33,7 +33,7 @@ def bing_spell_check_subscription_key(self) -> str: @bing_spell_check_subscription_key.setter def bing_spell_check_subscription_key(self, value: str) -> None: - """Gets or sets the Bing Spell Check subscription key. + """Sets the Bing Spell Check subscription key. :param value: The Bing Spell Check subscription key. :type value: str @@ -45,7 +45,7 @@ def bing_spell_check_subscription_key(self, value: str) -> None: @property def include_all_intents(self) -> bool: - """Gets or sets whether all intents come back or only the top one. + """Gets whether all intents come back or only the top one. :return: True for returning all intents. :rtype: bool @@ -55,7 +55,7 @@ def include_all_intents(self) -> bool: @include_all_intents.setter def include_all_intents(self, value: bool) -> None: - """Gets or sets whether all intents come back or only the top one. + """Sets whether all intents come back or only the top one. :param value: True for returning all intents. :type value: bool @@ -67,7 +67,7 @@ def include_all_intents(self, value: bool) -> None: @property def include_instance_data(self) -> bool: - """Gets or sets a value indicating whether or not instance data should be included in response. + """Gets a value indicating whether or not instance data should be included in response. :return: A value indicating whether or not instance data should be included in response. :rtype: bool @@ -77,7 +77,7 @@ def include_instance_data(self) -> bool: @include_instance_data.setter def include_instance_data(self, value: bool) -> None: - """Gets or sets a value indicating whether or not instance data should be included in response. + """Sets a value indicating whether or not instance data should be included in response. :param value: A value indicating whether or not instance data should be included in response. :type value: bool @@ -89,7 +89,7 @@ def include_instance_data(self, value: bool) -> None: @property def log(self) -> bool: - """Gets or sets if queries should be logged in LUIS. + """Gets if queries should be logged in LUIS. :return: If queries should be logged in LUIS. :rtype: bool @@ -99,7 +99,7 @@ def log(self) -> bool: @log.setter def log(self, value: bool) -> None: - """Gets or sets if queries should be logged in LUIS. + """Sets if queries should be logged in LUIS. :param value: If queries should be logged in LUIS. :type value: bool @@ -111,7 +111,7 @@ def log(self, value: bool) -> None: @property def spell_check(self) -> bool: - """Gets or sets whether to spell check queries. + """Gets whether to spell check queries. :return: Whether to spell check queries. :rtype: bool @@ -121,7 +121,7 @@ def spell_check(self) -> bool: @spell_check.setter def spell_check(self, value: bool) -> None: - """Gets or sets whether to spell check queries. + """Sets whether to spell check queries. :param value: Whether to spell check queries. :type value: bool @@ -133,7 +133,7 @@ def spell_check(self, value: bool) -> None: @property def staging(self) -> bool: - """Gets or sets whether to use the staging endpoint. + """Gets whether to use the staging endpoint. :return: Whether to use the staging endpoint. :rtype: bool @@ -143,7 +143,7 @@ def staging(self) -> bool: @staging.setter def staging(self, value: bool) -> None: - """Gets or sets whether to use the staging endpoint. + """Sets whether to use the staging endpoint. :param value: Whether to use the staging endpoint. @@ -156,7 +156,7 @@ def staging(self, value: bool) -> None: @property def timeout(self) -> float: - """Gets or sets the time in milliseconds to wait before the request times out. + """Gets the time in milliseconds to wait before the request times out. :return: The time in milliseconds to wait before the request times out. Default is 100000 milliseconds. :rtype: float @@ -166,7 +166,7 @@ def timeout(self) -> float: @timeout.setter def timeout(self, value: float) -> None: - """Gets or sets the time in milliseconds to wait before the request times out. + """Sets the time in milliseconds to wait before the request times out. :param value: The time in milliseconds to wait before the request times out. Default is 100000 milliseconds. :type value: float @@ -178,7 +178,7 @@ def timeout(self, value: float) -> None: @property def timezone_offset(self) -> float: - """Gets or sets the time zone offset. + """Gets the time zone offset. :return: The time zone offset. :rtype: float @@ -188,7 +188,7 @@ def timezone_offset(self) -> float: @timezone_offset.setter def timezone_offset(self, value: float) -> None: - """Gets or sets the time zone offset. + """Sets the time zone offset. :param value: The time zone offset. :type value: float @@ -200,7 +200,7 @@ def timezone_offset(self, value: float) -> None: @property def telemetry_client(self) -> BotTelemetryClient: - """Gets or sets the IBotTelemetryClient used to log the LuisResult event. + """Gets the BotTelemetryClient used to log the LuisResult event. :return: The client used to log telemetry events. :rtype: BotTelemetryClient @@ -210,7 +210,7 @@ def telemetry_client(self) -> BotTelemetryClient: @telemetry_client.setter def telemetry_client(self, value: BotTelemetryClient) -> None: - """Gets or sets the IBotTelemetryClient used to log the LuisResult event. + """Sets the BotTelemetryClient used to log the LuisResult event. :param value: The client used to log telemetry events. :type value: BotTelemetryClient @@ -222,7 +222,7 @@ def telemetry_client(self, value: BotTelemetryClient) -> None: @property def log_personal_information(self) -> bool: - """Gets or sets a value indicating whether to log personal information that came from the user to telemetry. + """Gets a value indicating whether to log personal information that came from the user to telemetry. :return: If true, personal information is logged to Telemetry; otherwise the properties will be filtered. :rtype: bool @@ -232,7 +232,7 @@ def log_personal_information(self) -> bool: @log_personal_information.setter def log_personal_information(self, value: bool) -> None: - """Gets or sets a value indicating whether to log personal information that came from the user to telemetry. + """Sets a value indicating whether to log personal information that came from the user to telemetry. :param value: If true, personal information is logged to Telemetry; otherwise the properties will be filtered. :type value: bool diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index c42df4724..72ebaa888 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -387,16 +387,17 @@ def assert_score(self, score: float) -> None: @classmethod def _get_recognizer_result( - cls, utterance: str, response_file: str, bot_adapter: BotAdapter = TestAdapter() + cls, + utterance: str, + response_file: str, + bot_adapter: BotAdapter = TestAdapter(), + verbose: bool = False, + options: LuisPredictionOptions = None, ) -> Tuple[LuisRecognizer, RecognizerResult]: response_json = LuisRecognizerTest._get_json_for_file(response_file) - - my_app = LuisApplication( - LuisRecognizerTest._luisAppId, - LuisRecognizerTest._subscriptionKey, - endpoint="", + recognizer = LuisRecognizerTest._get_luis_recognizer( + verbose=verbose, options=options ) - recognizer = LuisRecognizer(my_app, prediction_options=None) context = LuisRecognizerTest._get_context(utterance, bot_adapter) response = Mock(spec=Response) response.status_code = 200 @@ -425,7 +426,9 @@ def _get_luis_recognizer( cls, verbose: bool = False, options: LuisPredictionOptions = None ) -> LuisRecognizer: luis_app = LuisApplication(cls._luisAppId, cls._subscriptionKey, cls._endpoint) - return LuisRecognizer(luis_app, options, verbose) + return LuisRecognizer( + luis_app, prediction_options=options, include_api_results=verbose + ) @staticmethod def _get_context(utterance: str, bot_adapter: BotAdapter) -> TurnContext: From 1a448d846da6b125156c6a0f178db35352dcd5ea Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 29 Apr 2019 14:09:05 -0700 Subject: [PATCH 68/73] build fixing, sync with appinsights needed --- .../botbuilder/core/bot_state.py | 5 +++ .../botbuilder/core/memory_storage.py | 23 ++++++----- libraries/botbuilder-core/tests/__init__.py | 10 ----- .../botbuilder-core/tests/test_bot_state.py | 10 ++--- .../tests/test_conversation_state.py | 38 ++---------------- .../tests/test_memory_storage.py | 40 ++++++++----------- .../tests/test_message_factory.py | 1 + .../tests/test_test_adapter.py | 3 +- .../botbuilder-core/tests/test_user_state.py | 27 +++++++------ .../botbuilder-dialogs/tests/__init__.py | 2 - .../tests/choices/__init__.py | 0 .../tests/test_number_prompt.py | 2 +- .../tests/test_prompt_validator_context.py | 8 ++-- .../botframework-connector/tests/__init__.py | 2 - .../tests/test_attachments.py | 2 +- .../tests/test_attachments_async.py | 2 +- .../tests/test_conversations.py | 2 +- .../tests/test_conversations_async.py | 2 +- samples/Core-Bot/helpers/__init__.py | 2 +- 19 files changed, 70 insertions(+), 111 deletions(-) delete mode 100644 libraries/botbuilder-core/tests/__init__.py delete mode 100644 libraries/botbuilder-dialogs/tests/__init__.py delete mode 100644 libraries/botbuilder-dialogs/tests/choices/__init__.py delete mode 100644 libraries/botframework-connector/tests/__init__.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index c307435d6..68a38b0cc 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -63,6 +63,11 @@ def create_property(self, name:str) -> StatePropertyAccessor: raise TypeError('BotState.create_property(): BotState cannot be None or empty.') return BotStatePropertyAccessor(self, name) + def get(self, turn_context: TurnContext) -> Dict[str, object]: + cached = turn_context.turn_state.get(self._context_service_key) + + return getattr(cached, 'state', None) + async def load(self, turn_context: TurnContext, force: bool = False) -> None: """ diff --git a/libraries/botbuilder-core/botbuilder/core/memory_storage.py b/libraries/botbuilder-core/botbuilder/core/memory_storage.py index d602a1949..793e01d91 100644 --- a/libraries/botbuilder-core/botbuilder/core/memory_storage.py +++ b/libraries/botbuilder-core/botbuilder/core/memory_storage.py @@ -25,7 +25,7 @@ async def read(self, keys: List[str]): try: for key in keys: if key in self.memory: - data[key] = self.memory[key] + data[key] = deepcopy(self.memory[key]) except TypeError as e: raise e @@ -35,26 +35,28 @@ async def write(self, changes: Dict[str, StoreItem]): try: # iterate over the changes for (key, change) in changes.items(): + #import pdb; pdb.set_trace() new_value = change old_state = None - old_state_etag = "" + old_state_etag = None # Check if the a matching key already exists in self.memory # If it exists then we want to cache its original value from memory if key in self.memory: old_state = self.memory[key] - if "eTag" in old_state: - old_state_etag = old_state["eTag"] + if not isinstance(old_state, StoreItem): + if "eTag" in old_state: + old_state_etag = old_state["eTag"] + elif old_state.e_tag: + old_state_etag = old_state.e_tag - new_state = new_value + new_state = deepcopy(new_value) # Set ETag if applicable if isinstance(new_value, StoreItem): - new_store_item = new_value - if not old_state_etag is StoreItem: - if not new_store_item is "*" and new_store_item.e_tag != old_state_etag: - raise Exception("Etag conflict.\nOriginal: %s\r\nCurrent: {%s}" % \ - (new_store_item.e_tag, old_state_etag) ) + if old_state_etag is not None and new_value.e_tag != "*" and new_value.e_tag < old_state_etag: + raise KeyError("Etag conflict.\nOriginal: %s\r\nCurrent: %s" % \ + (new_value.e_tag, old_state_etag) ) new_state.e_tag = str(self._e_tag) self._e_tag += 1 self.memory[key] = new_state @@ -62,6 +64,7 @@ async def write(self, changes: Dict[str, StoreItem]): except Exception as e: raise e + #TODO: Check if needed, if not remove def __should_write_changes(self, old_value: StoreItem, new_value: StoreItem) -> bool: """ Helper method that compares two StoreItems and their e_tags and returns True if the new_value should overwrite diff --git a/libraries/botbuilder-core/tests/__init__.py b/libraries/botbuilder-core/tests/__init__.py deleted file mode 100644 index 2c90d1f71..000000000 --- a/libraries/botbuilder-core/tests/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# coding=utf-8 -# -------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for -# license information. -# -------------------------------------------------------------------------- - -from .test_utilities import TestUtilities - -__all__ = ['TestUtilities'] diff --git a/libraries/botbuilder-core/tests/test_bot_state.py b/libraries/botbuilder-core/tests/test_bot_state.py index f7671db3f..f44da08a7 100644 --- a/libraries/botbuilder-core/tests/test_bot_state.py +++ b/libraries/botbuilder-core/tests/test_bot_state.py @@ -7,7 +7,7 @@ from botbuilder.core.adapters import TestAdapter from botbuilder.schema import Activity -from .test_utilities import TestUtilities +from test_utilities import TestUtilities RECEIVED_MESSAGE = Activity(type='message', text='received') @@ -287,8 +287,8 @@ async def test_LoadSetSaveTwice(self): property_b2 = user_state2.create_property("property-b") await user_state2.load(context) - await property_a.set(context, "hello-2") - await property_b.set(context, "world-2") + await property_a2.set(context, "hello-2") + await property_b2.set(context, "world-2") await user_state2.save_changes(context) # Assert 2 @@ -326,8 +326,8 @@ async def test_LoadSaveDelete(self): property_b2 = user_state2.create_property("property-b") await user_state2.load(context) - await property_a.set(context, "hello-2") - await property_b.delete(context) + await property_a2.set(context, "hello-2") + await property_b2.delete(context) await user_state2.save_changes(context) # Assert 2 diff --git a/libraries/botbuilder-core/tests/test_conversation_state.py b/libraries/botbuilder-core/tests/test_conversation_state.py index c4fac84b7..72df6a88c 100644 --- a/libraries/botbuilder-core/tests/test_conversation_state.py +++ b/libraries/botbuilder-core/tests/test_conversation_state.py @@ -3,7 +3,8 @@ import aiounittest -from botbuilder.core import TurnContext, MemoryStorage, TestAdapter, ConversationState +from botbuilder.core import TurnContext, MemoryStorage, ConversationState +from botbuilder.core.adapters import TestAdapter from botbuilder.schema import Activity, ConversationAccount RECEIVED_MESSAGE = Activity(type='message', @@ -21,46 +22,13 @@ conversation=ConversationAccount(id='convo')) -class TestConversationState: +class TestConversationState(aiounittest.AsyncTestCase): storage = MemoryStorage() adapter = TestAdapter() context = TurnContext(adapter, RECEIVED_MESSAGE) middleware = ConversationState(storage) - async def test_should_load_and_save_state_from_storage(self): - key = None - - async def next_middleware(): - nonlocal key - key = self.middleware.get_storage_key(self.context) - state = await self.middleware.get(self.context) - assert state is not None, 'State not loaded' - assert key is not None, 'Key not found' - state.test = 'foo' - - await self.middleware.on_process_request(self.context, next_middleware) - - items = await self.storage.read([key]) - assert key in items, 'Saved state not found in storage.' - assert items[key].test == 'foo', 'Missing test value in stored state.' - - - async def test_should_ignore_any_activities_that_are_not_endOfConversation(self): - key = None - - async def next_middleware(): - nonlocal key - key = self.middleware.get_storage_key(self.context) - state = await self.middleware.get(self.context) - assert state.test == 'foo', 'invalid initial state' - await self.context.send_activity(Activity(type='message', text='foo')) - - await self.middleware.on_process_request(self.context, next_middleware) - items = await self.storage.read([key]) - assert hasattr(items[key], 'test'), 'state cleared and should not have been' - - async def test_should_reject_with_error_if_channel_id_is_missing(self): context = TurnContext(self.adapter, MISSING_CHANNEL_ID) diff --git a/libraries/botbuilder-core/tests/test_memory_storage.py b/libraries/botbuilder-core/tests/test_memory_storage.py index 7185fdc4d..5da89b0c8 100644 --- a/libraries/botbuilder-core/tests/test_memory_storage.py +++ b/libraries/botbuilder-core/tests/test_memory_storage.py @@ -7,7 +7,7 @@ class SimpleStoreItem(StoreItem): - def __init__(self, counter=1, e_tag='0'): + def __init__(self, counter=1, e_tag='*'): super(SimpleStoreItem, self).__init__() self.counter = counter self.e_tag = e_tag @@ -41,33 +41,20 @@ async def test_memory_storage_read_should_return_data_with_valid_key(self): assert data['user'].counter == 1 assert len(data.keys()) == 1 assert storage._e_tag == 1 - assert int(data['user'].e_tag) == 1 + assert int(data['user'].e_tag) == 0 async def test_memory_storage_write_should_add_new_value(self): storage = MemoryStorage() - await storage.write({'user': SimpleStoreItem(counter=1)}) + aux = {'user': SimpleStoreItem(counter=1)} + await storage.write(aux) data = await storage.read(['user']) assert 'user' in data assert data['user'].counter == 1 - + - async def test_memory_storage_write_should_overwrite_cached_value_with_valid_newer_e_tag(self): - storage = MemoryStorage() - await storage.write({'user': SimpleStoreItem()}) - data = await storage.read(['user']) - - try: - await storage.write({'user': SimpleStoreItem(counter=2, e_tag='2')}) - data = await storage.read(['user']) - assert data['user'].counter == 2 - assert int(data['user'].e_tag) == 2 - except Exception as e: - raise e - - - async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk(self): + async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk_1(self): storage = MemoryStorage() await storage.write({'user': SimpleStoreItem(e_tag='1')}) @@ -76,7 +63,7 @@ async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asteri assert data['user'].counter == 10 - async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk(self): + async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asterisk_2(self): storage = MemoryStorage() await storage.write({'user': SimpleStoreItem(e_tag='1')}) @@ -87,13 +74,18 @@ async def test_memory_storage_write_should_overwrite_when_new_e_tag_is_an_asteri async def test_memory_storage_write_should_raise_a_key_error_with_older_e_tag(self): storage = MemoryStorage() - await storage.write({'user': SimpleStoreItem(e_tag='1')}) + first_item = SimpleStoreItem(e_tag='0') + + await storage.write({'user': first_item}) + + updated_item = (await storage.read(['user']))['user'] + updated_item.counter += 1 + await storage.write({'user': first_item}) - await storage.read(['user']) try: - await storage.write({'user': SimpleStoreItem()}) + await storage.write({'user': first_item}) await storage.read(['user']) - except KeyError as e: + except KeyError as _: pass else: raise AssertionError("test_memory_storage_read_should_raise_a_key_error_with_invalid_e_tag(): should have " diff --git a/libraries/botbuilder-core/tests/test_message_factory.py b/libraries/botbuilder-core/tests/test_message_factory.py index 83d6dfcab..7c669ac19 100644 --- a/libraries/botbuilder-core/tests/test_message_factory.py +++ b/libraries/botbuilder-core/tests/test_message_factory.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import aiounittest from typing import List from botbuilder.core import MessageFactory diff --git a/libraries/botbuilder-core/tests/test_test_adapter.py b/libraries/botbuilder-core/tests/test_test_adapter.py index 9785fd93f..abcaad824 100644 --- a/libraries/botbuilder-core/tests/test_test_adapter.py +++ b/libraries/botbuilder-core/tests/test_test_adapter.py @@ -3,7 +3,8 @@ import aiounittest from botbuilder.schema import Activity, ConversationReference -from botbuilder.core import TurnContext, TestAdapter +from botbuilder.core import TurnContext +from botbuilder.core.adapters import TestAdapter from datetime import datetime RECEIVED_MESSAGE = Activity(type='message', text='received') diff --git a/libraries/botbuilder-core/tests/test_user_state.py b/libraries/botbuilder-core/tests/test_user_state.py index bac1fc357..18e0e1ce3 100644 --- a/libraries/botbuilder-core/tests/test_user_state.py +++ b/libraries/botbuilder-core/tests/test_user_state.py @@ -3,7 +3,8 @@ import aiounittest -from botbuilder.core import TurnContext, MemoryStorage, StoreItem, TestAdapter, UserState +from botbuilder.core import TurnContext, MemoryStorage, StoreItem, UserState +from botbuilder.core.adapters import TestAdapter from botbuilder.schema import Activity, ChannelAccount RECEIVED_MESSAGE = Activity(type='message', @@ -22,22 +23,24 @@ class TestUserState(aiounittest.AsyncTestCase): storage = MemoryStorage() adapter = TestAdapter() context = TurnContext(adapter, RECEIVED_MESSAGE) - middleware = UserState(storage) + user_state = UserState(storage) async def test_should_load_and_save_state_from_storage(self): + await self.user_state.load(self.context) + key = self.user_state.get_storage_key(self.context) + state = self.user_state.get(self.context) - async def next_middleware(): - state = await self.middleware.get(self.context) - assert isinstance(state, StoreItem), 'State not loaded' - state.test = 'foo' + assert state is not None, 'State not loaded' + assert key, 'Key not found' + + state['test'] = 'foo' + await self.user_state.save_changes(self.context) - await self.middleware.on_process_request(self.context, next_middleware) - key = self.middleware.get_storage_key(self.context) - assert type(key) == str, 'Key not found' items = await self.storage.read([key]) + assert key in items, 'Saved state not found in storage' - assert items[key].test == 'foo', 'Missing test value in stored state.' + assert items[key]['test'] == 'foo', 'Missing saved value in stored storage' async def test_should_reject_with_error_if_channel_id_is_missing(self): @@ -47,7 +50,7 @@ async def next_middleware(): assert False, 'Should not have called next_middleware' try: - await self.middleware.on_process_request(context, next_middleware) + await self.user_state.on_process_request(context, next_middleware) except AttributeError: pass else: @@ -61,7 +64,7 @@ async def next_middleware(): assert False, 'Should not have called next_middleware' try: - await self.middleware.on_process_request(context, next_middleware) + await self.user_state.on_process_request(context, next_middleware) except AttributeError: pass else: diff --git a/libraries/botbuilder-dialogs/tests/__init__.py b/libraries/botbuilder-dialogs/tests/__init__.py deleted file mode 100644 index 81e13df46..000000000 --- a/libraries/botbuilder-dialogs/tests/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# pylint: disable=missing-docstring -__import__('pkg_resources').declare_namespace(__name__) diff --git a/libraries/botbuilder-dialogs/tests/choices/__init__.py b/libraries/botbuilder-dialogs/tests/choices/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/libraries/botbuilder-dialogs/tests/test_number_prompt.py b/libraries/botbuilder-dialogs/tests/test_number_prompt.py index 5428893fd..88e25fd98 100644 --- a/libraries/botbuilder-dialogs/tests/test_number_prompt.py +++ b/libraries/botbuilder-dialogs/tests/test_number_prompt.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import aiounittest -from botbuilder.dialogs.prompts import NumberPrompt +from botbuilder.dialogs.prompts import NumberPrompt class NumberPromptTests(aiounittest.AsyncTestCase): def test_empty_should_fail(self): diff --git a/libraries/botbuilder-dialogs/tests/test_prompt_validator_context.py b/libraries/botbuilder-dialogs/tests/test_prompt_validator_context.py index 731dec1ec..078b18945 100644 --- a/libraries/botbuilder-dialogs/tests/test_prompt_validator_context.py +++ b/libraries/botbuilder-dialogs/tests/test_prompt_validator_context.py @@ -10,18 +10,18 @@ class PromptValidatorContextTests(aiounittest.AsyncTestCase): async def test_prompt_validator_context_end(self): - storage = MemoryStorage(); + storage = MemoryStorage() conv = ConversationState(storage) accessor = conv.create_property("dialogstate") - ds = DialogSet(accessor); + ds = DialogSet(accessor) self.assertNotEqual(ds, None) # TODO: Add TestFlow def test_prompt_validator_context_retry_end(self): - storage = MemoryStorage(); + storage = MemoryStorage() conv = ConversationState(storage) accessor = conv.create_property("dialogstate") - ds = DialogSet(accessor); + ds = DialogSet(accessor) self.assertNotEqual(ds, None) # TODO: Add TestFlow diff --git a/libraries/botframework-connector/tests/__init__.py b/libraries/botframework-connector/tests/__init__.py deleted file mode 100644 index 81e13df46..000000000 --- a/libraries/botframework-connector/tests/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# pylint: disable=missing-docstring -__import__('pkg_resources').declare_namespace(__name__) diff --git a/libraries/botframework-connector/tests/test_attachments.py b/libraries/botframework-connector/tests/test_attachments.py index add9027ba..1225f6e17 100644 --- a/libraries/botframework-connector/tests/test_attachments.py +++ b/libraries/botframework-connector/tests/test_attachments.py @@ -8,7 +8,7 @@ from botframework.connector import ConnectorClient from botframework.connector.auth import MicrosoftAppCredentials -from .authentication_stub import MicrosoftTokenAuthenticationStub +from authentication_stub import MicrosoftTokenAuthenticationStub SERVICE_URL = 'https://slack.botframework.com' CHANNEL_ID = 'slack' diff --git a/libraries/botframework-connector/tests/test_attachments_async.py b/libraries/botframework-connector/tests/test_attachments_async.py index 70e797085..ac12c1b58 100644 --- a/libraries/botframework-connector/tests/test_attachments_async.py +++ b/libraries/botframework-connector/tests/test_attachments_async.py @@ -9,7 +9,7 @@ from botframework.connector.aio import ConnectorClient from botframework.connector.auth import MicrosoftAppCredentials -from .authentication_stub import MicrosoftTokenAuthenticationStub +from authentication_stub import MicrosoftTokenAuthenticationStub SERVICE_URL = 'https://slack.botframework.com' CHANNEL_ID = 'slack' diff --git a/libraries/botframework-connector/tests/test_conversations.py b/libraries/botframework-connector/tests/test_conversations.py index a5c72f4d7..17233f957 100644 --- a/libraries/botframework-connector/tests/test_conversations.py +++ b/libraries/botframework-connector/tests/test_conversations.py @@ -5,7 +5,7 @@ from botframework.connector import ConnectorClient from botframework.connector.auth import MicrosoftAppCredentials -from .authentication_stub import MicrosoftTokenAuthenticationStub +from authentication_stub import MicrosoftTokenAuthenticationStub SERVICE_URL = 'https://slack.botframework.com' CHANNEL_ID = 'slack' diff --git a/libraries/botframework-connector/tests/test_conversations_async.py b/libraries/botframework-connector/tests/test_conversations_async.py index 6bf40bd26..acbbede80 100644 --- a/libraries/botframework-connector/tests/test_conversations_async.py +++ b/libraries/botframework-connector/tests/test_conversations_async.py @@ -6,7 +6,7 @@ from botframework.connector.aio import ConnectorClient from botframework.connector.auth import MicrosoftAppCredentials -from .authentication_stub import MicrosoftTokenAuthenticationStub +from authentication_stub import MicrosoftTokenAuthenticationStub SERVICE_URL = 'https://slack.botframework.com' CHANNEL_ID = 'slack' diff --git a/samples/Core-Bot/helpers/__init__.py b/samples/Core-Bot/helpers/__init__.py index 3b145db5f..a169b42cc 100644 --- a/samples/Core-Bot/helpers/__init__.py +++ b/samples/Core-Bot/helpers/__init__.py @@ -2,5 +2,5 @@ __all__ = [ 'activity_helper', - 'dialog_helper' + 'dialog_helper', 'luis_helper'] \ No newline at end of file From be23f75d9e790eba8d72cd51e55f6cd7fb32ad14 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 29 Apr 2019 14:49:02 -0700 Subject: [PATCH 69/73] skipping tests with pending class --- libraries/botbuilder-ai/tests/luis/__init__.py | 0 libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py | 2 +- libraries/botbuilder-ai/tests/qna/__init__.py | 0 .../tests/test_telemetry_waterfall.py | 5 ++++- 4 files changed, 5 insertions(+), 2 deletions(-) delete mode 100644 libraries/botbuilder-ai/tests/luis/__init__.py delete mode 100644 libraries/botbuilder-ai/tests/qna/__init__.py diff --git a/libraries/botbuilder-ai/tests/luis/__init__.py b/libraries/botbuilder-ai/tests/luis/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index 72ebaa888..896aa649f 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -32,7 +32,7 @@ ConversationAccount, ) -from .null_adapter import NullAdapter +from null_adapter import NullAdapter class LuisRecognizerTest(unittest.TestCase): diff --git a/libraries/botbuilder-ai/tests/qna/__init__.py b/libraries/botbuilder-ai/tests/qna/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py index 6d1dadb11..cabd63c65 100644 --- a/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py +++ b/libraries/botbuilder-applicationinsights/tests/test_telemetry_waterfall.py @@ -30,6 +30,7 @@ DialogTurnStatus ) from unittest.mock import patch, Mock +from unittest import skip begin_message = Activity() begin_message.text = 'begin' @@ -44,6 +45,7 @@ def test_none_telemetry_client(self): # assert self.assertEqual(type(dialog.telemetry_client), NullTelemetryClient) + @skip('Pending Telemetry mock') @patch('test_telemetry_waterfall.ApplicationInsightsTelemetryClient') async def test_execute_sequence_waterfall_steps(self, MockTelemetry): # arrange @@ -97,7 +99,8 @@ async def exec_test(turn_context: TurnContext) -> None: ('WaterfallStep', {'DialogId':'test', 'StepName':'Step2of2'}) ] self.assert_telemetry_calls(telemetry, telemetry_calls) - + + @skip('Pending Telemetry mock') @patch('test_telemetry_waterfall.ApplicationInsightsTelemetryClient') async def test_ensure_end_dialog_called(self, MockTelemetry): # arrange From 99a15a13c1f9a6e4b135e016f28ba4c576dad4f6 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 29 Apr 2019 15:15:52 -0700 Subject: [PATCH 70/73] remove unnecessary () --- libraries/botbuilder-azure/tests/test_cosmos_storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-azure/tests/test_cosmos_storage.py b/libraries/botbuilder-azure/tests/test_cosmos_storage.py index 9b764fa22..af89edbf3 100644 --- a/libraries/botbuilder-azure/tests/test_cosmos_storage.py +++ b/libraries/botbuilder-azure/tests/test_cosmos_storage.py @@ -3,7 +3,7 @@ import pytest from botbuilder.core import StoreItem -from botbuilder.azure import (CosmosDbStorage, CosmosDbConfig) +from botbuilder.azure import CosmosDbStorage, CosmosDbConfig # local cosmosdb emulator instance cosmos_db_config cosmos_db_config = CosmosDbConfig( From 94289ba435505263e7d1b564cb047773aca31c99 Mon Sep 17 00:00:00 2001 From: congysu Date: Mon, 29 Apr 2019 15:22:25 -0700 Subject: [PATCH 71/73] add trace activity --- .../botbuilder/ai/luis/activity_util.py | 69 +++++++++++++++++++ .../botbuilder/ai/luis/luis_recognizer.py | 32 +++++++-- .../tests/luis/luis_recognizer_test.py | 66 +++++++++++------- .../botbuilder-ai/tests/luis/null_adapter.py | 2 +- 4 files changed, 137 insertions(+), 32 deletions(-) create mode 100644 libraries/botbuilder-ai/botbuilder/ai/luis/activity_util.py diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/activity_util.py b/libraries/botbuilder-ai/botbuilder/ai/luis/activity_util.py new file mode 100644 index 000000000..3db60182c --- /dev/null +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/activity_util.py @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime + +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) + + +class ActivityUtil(object): + @staticmethod + def create_trace( + turn_activity: Activity, + name: str, + value: object = None, + value_type: str = None, + label: str = None, + ) -> Activity: + """Creates a trace activity based on this activity. + + :param turn_activity: + :type turn_activity: Activity + :param name: The value to assign to the trace activity's property. + :type name: str + :param value: The value to assign to the trace activity's property., defaults to None + :param value: object, optional + :param value_type: The value to assign to the trace activity's property, defaults to None + :param value_type: str, optional + :param label: The value to assign to the trace activity's property, defaults to None + :param label: str, optional + :return: The created trace activity. + :rtype: Activity + """ + + from_property = ( + ChannelAccount( + id=turn_activity.recipient.id, name=turn_activity.recipient.name + ) + if turn_activity.recipient is not None + else ChannelAccount() + ) + if value_type is None and value is not None: + value_type = type(value).__name__ + + reply = Activity( + type=ActivityTypes.trace, + timestamp=datetime.utcnow(), + from_property=from_property, + recipient=ChannelAccount( + id=turn_activity.from_property.id, name=turn_activity.from_property.name + ), + reply_to_id=turn_activity.id, + service_url=turn_activity.service_url, + channel_id=turn_activity.channel_id, + conversation=ConversationAccount( + is_group=turn_activity.conversation.is_group, + id=turn_activity.conversation.id, + name=turn_activity.conversation.name, + ), + name=name, + label=label, + value_type=value_type, + value=value, + ) + return reply diff --git a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py index 87229e4be..251a23cb0 100644 --- a/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py +++ b/libraries/botbuilder-ai/botbuilder/ai/luis/luis_recognizer.py @@ -14,7 +14,7 @@ NullTelemetryClient, TurnContext, ) -from botbuilder.schema import ActivityTypes +from botbuilder.schema import Activity, ActivityTypes, ChannelAccount from . import ( IntentScore, @@ -23,6 +23,7 @@ LuisTelemetryConstants, RecognizerResult, ) +from .activity_util import ActivityUtil from .luis_util import LuisUtil @@ -148,7 +149,7 @@ def top_intent( return top_intent or default_intent - def recognize( + async def recognize( self, turn_context: TurnContext, telemetry_properties: Dict[str, str] = None, @@ -166,7 +167,7 @@ def recognize( :rtype: RecognizerResult """ - return self._recognize_internal( + return await self._recognize_internal( turn_context, telemetry_properties, telemetry_metrics ) @@ -282,7 +283,7 @@ def fill_luis_event_properties( return properties - def _recognize_internal( + async def _recognize_internal( self, turn_context: TurnContext, telemetry_properties: Dict[str, str], @@ -306,11 +307,11 @@ def _recognize_internal( luis_result = self._runtime.prediction.resolve( self._application.application_id, utterance, - timezoneOffset=self._options.timezone_offset, + timezone_offset=self._options.timezone_offset, verbose=self._options.include_all_intents, staging=self._options.staging, - spellCheck=self._options.spell_check, - bingSpellCheckSubscriptionKey=self._options.bing_spell_check_subscription_key, + spell_check=self._options.spell_check, + bing_spell_check_subscription_key=self._options.bing_spell_check_subscription_key, log=self._options.log if self._options.log is not None else True, ) @@ -335,4 +336,21 @@ def _recognize_internal( recognizer_result, turn_context, telemetry_properties, telemetry_metrics ) + trace_info: Dict[str, object] = { + "recognizerResult": recognizer_result, + "luisModel": {"ModelID": self._application.application_id}, + "luisOptions": self._options, + "luisResult": luis_result, + } + + trace_activity = ActivityUtil.create_trace( + turn_context.activity, + "LuisRecognizer", + trace_info, + LuisRecognizer.luis_trace_type, + LuisRecognizer.luis_trace_label, + ) + + await turn_context.send_activity(trace_activity) + return recognizer_result diff --git a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py index 896aa649f..0043a7a53 100644 --- a/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py +++ b/libraries/botbuilder-ai/tests/luis/luis_recognizer_test.py @@ -2,12 +2,12 @@ # Licensed under the MIT License. import json -import unittest from os import path from typing import Tuple from unittest.mock import Mock, patch import requests +from aiounittest import AsyncTestCase from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient from azure.cognitiveservices.language.luis.runtime.luis_runtime_client import ( LUISRuntimeClientConfiguration, @@ -35,7 +35,7 @@ from null_adapter import NullAdapter -class LuisRecognizerTest(unittest.TestCase): +class LuisRecognizerTest(AsyncTestCase): _luisAppId: str = "b31aeaf3-3511-495b-a07f-571fc873214b" _subscriptionKey: str = "048ec46dc58e495482b0c447cfdbd291" _endpoint: str = "https://westus.api.cognitive.microsoft.com" @@ -104,11 +104,13 @@ def test_luis_recognizer_none_luis_app_arg(self): with self.assertRaises(TypeError): LuisRecognizer(application=None) - def test_single_intent_simply_entity(self): + async def test_single_intent_simply_entity(self): utterance: str = "My name is Emad" response_path: str = "SingleIntent_SimplyEntity.json" - _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = await LuisRecognizerTest._get_recognizer_result( + utterance, response_path + ) self.assertIsNotNone(result) self.assertIsNone(result.altered_text) @@ -126,11 +128,13 @@ def test_single_intent_simply_entity(self): self.assertEqual(15, result.entities["$instance"]["Name"][0]["endIndex"]) self.assert_score(result.entities["$instance"]["Name"][0]["score"]) - def test_null_utterance(self): + async def test_null_utterance(self): utterance: str = None response_path: str = "SingleIntent_SimplyEntity.json" # The path is irrelevant in this case - _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = await LuisRecognizerTest._get_recognizer_result( + utterance, response_path + ) self.assertIsNotNone(result) self.assertIsNone(result.altered_text) @@ -142,11 +146,13 @@ def test_null_utterance(self): self.assertIsNotNone(result.entities) self.assertEqual(0, len(result.entities)) - def test_multiple_intents_prebuilt_entity(self): + async def test_multiple_intents_prebuilt_entity(self): utterance: str = "Please deliver February 2nd 2001" response_path: str = "MultipleIntents_PrebuiltEntity.json" - _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = await LuisRecognizerTest._get_recognizer_result( + utterance, response_path + ) self.assertIsNotNone(result) self.assertEqual(utterance, result.text) @@ -179,11 +185,13 @@ def test_multiple_intents_prebuilt_entity(self): "february 2nd 2001", result.entities["$instance"]["datetime"][0]["text"] ) - def test_multiple_intents_prebuilt_entities_with_multi_values(self): + async def test_multiple_intents_prebuilt_entities_with_multi_values(self): utterance: str = "Please deliver February 2nd 2001 in room 201" response_path: str = "MultipleIntents_PrebuiltEntitiesWithMultiValues.json" - _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = await LuisRecognizerTest._get_recognizer_result( + utterance, response_path + ) self.assertIsNotNone(result) self.assertIsNotNone(result.text) @@ -198,11 +206,13 @@ def test_multiple_intents_prebuilt_entities_with_multi_values(self): self.assertIsNotNone(result.entities["datetime"][0]) self.assertEqual("2001-02-02", result.entities["datetime"][0]["timex"][0]) - def test_multiple_intents_list_entity_with_single_value(self): + async def test_multiple_intents_list_entity_with_single_value(self): utterance: str = "I want to travel on united" response_path: str = "MultipleIntents_ListEntityWithSingleValue.json" - _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = await LuisRecognizerTest._get_recognizer_result( + utterance, response_path + ) self.assertIsNotNone(result) self.assertIsNotNone(result.text) @@ -218,11 +228,13 @@ def test_multiple_intents_list_entity_with_single_value(self): self.assertEqual(26, result.entities["$instance"]["Airline"][0]["endIndex"]) self.assertEqual("united", result.entities["$instance"]["Airline"][0]["text"]) - def test_multiple_intents_list_entity_with_multi_values(self): + async def test_multiple_intents_list_entity_with_multi_values(self): utterance: str = "I want to travel on DL" response_path: str = "MultipleIntents_ListEntityWithMultiValues.json" - _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = await LuisRecognizerTest._get_recognizer_result( + utterance, response_path + ) self.assertIsNotNone(result) self.assertIsNotNone(result.text) @@ -240,11 +252,13 @@ def test_multiple_intents_list_entity_with_multi_values(self): self.assertEqual(22, result.entities["$instance"]["Airline"][0]["endIndex"]) self.assertEqual("dl", result.entities["$instance"]["Airline"][0]["text"]) - def test_multiple_intents_composite_entity_model(self): + async def test_multiple_intents_composite_entity_model(self): utterance: str = "Please deliver it to 98033 WA" response_path: str = "MultipleIntents_CompositeEntityModel.json" - _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = await LuisRecognizerTest._get_recognizer_result( + utterance, response_path + ) self.assertIsNotNone(result) self.assertIsNotNone(result.text) @@ -290,11 +304,13 @@ def test_multiple_intents_composite_entity_model(self): result.entities["Address"][0]["$instance"]["State"][0]["score"] ) - def test_multiple_date_time_entities(self): + async def test_multiple_date_time_entities(self): utterance: str = "Book a table on Friday or tomorrow at 5 or tomorrow at 4" response_path: str = "MultipleDateTimeEntities.json" - _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = await LuisRecognizerTest._get_recognizer_result( + utterance, response_path + ) self.assertIsNotNone(result.entities["datetime"]) self.assertEqual(3, len(result.entities["datetime"])) @@ -309,11 +325,13 @@ def test_multiple_date_time_entities(self): self.assertTrue(result.entities["datetime"][2]["timex"][1].endswith("T16")) self.assertEqual(3, len(result.entities["$instance"]["datetime"])) - def test_v1_datetime_resolution(self): + async def test_v1_datetime_resolution(self): utterance: str = "at 4" response_path: str = "V1DatetimeResolution.json" - _, result = LuisRecognizerTest._get_recognizer_result(utterance, response_path) + _, result = await LuisRecognizerTest._get_recognizer_result( + utterance, response_path + ) self.assertIsNotNone(result.entities["datetime_time"]) self.assertEqual(1, len(result.entities["datetime_time"])) @@ -348,11 +366,11 @@ def test_top_intent_returns_top_intent_if_score_equals_min_score(self): ) self.assertEqual(default_intent, "Greeting") - def test_user_agent_contains_product_version(self): + async def test_user_agent_contains_product_version(self): utterance: str = "please book from May 5 to June 6" response_path: str = "MultipleDateTimeEntities.json" # it doesn't matter to use which file. - recognizer, _ = LuisRecognizerTest._get_recognizer_result( + recognizer, _ = await LuisRecognizerTest._get_recognizer_result( utterance, response_path, bot_adapter=NullAdapter() ) @@ -386,7 +404,7 @@ def assert_score(self, score: float) -> None: self.assertTrue(score <= 1) @classmethod - def _get_recognizer_result( + async def _get_recognizer_result( cls, utterance: str, response_file: str, @@ -408,7 +426,7 @@ def _get_recognizer_result( "msrest.serialization.Deserializer._unpack_content", return_value=response_json, ): - result = recognizer.recognize(context) + result = await recognizer.recognize(context) return recognizer, result @classmethod diff --git a/libraries/botbuilder-ai/tests/luis/null_adapter.py b/libraries/botbuilder-ai/tests/luis/null_adapter.py index d00a25271..8c8835c14 100644 --- a/libraries/botbuilder-ai/tests/luis/null_adapter.py +++ b/libraries/botbuilder-ai/tests/luis/null_adapter.py @@ -12,7 +12,7 @@ class NullAdapter(BotAdapter): This is a BotAdapter that does nothing on the Send operation, equivalent to piping to /dev/null. """ - def send_activities(self, context: TurnContext, activities: List[Activity]): + async def send_activities(self, context: TurnContext, activities: List[Activity]): return [ResourceResponse()] async def update_activity(self, context: TurnContext, activity: Activity): From 6ae4f25eb3961479b25e87044599940c3b920086 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 29 Apr 2019 15:47:56 -0700 Subject: [PATCH 72/73] fixed package versioning requirements for botbuilder azure --- libraries/botbuilder-ai/requirements.txt | 2 +- libraries/botbuilder-azure/setup.cfg | 2 ++ libraries/botbuilder-azure/setup.py | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 libraries/botbuilder-azure/setup.cfg diff --git a/libraries/botbuilder-ai/requirements.txt b/libraries/botbuilder-ai/requirements.txt index 9ace9b6c4..233fda047 100644 --- a/libraries/botbuilder-ai/requirements.txt +++ b/libraries/botbuilder-ai/requirements.txt @@ -3,4 +3,4 @@ botbuilder-schema>=4.0.0.a6 botbuilder-core>=4.0.0.a6 requests>=2.18.1 aiounittest>=1.1.0 -azure-cognitiveservices-language-luis==0.2.0 \ No newline at end of file +azure-cognitiveservices-language-luis>=0.2.0 \ No newline at end of file diff --git a/libraries/botbuilder-azure/setup.cfg b/libraries/botbuilder-azure/setup.cfg new file mode 100644 index 000000000..68c61a226 --- /dev/null +++ b/libraries/botbuilder-azure/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=0 \ No newline at end of file diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index ad3ebeb45..606c80a42 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -4,10 +4,10 @@ import os from setuptools import setup -REQUIRES = ['azure-cosmos==3.0.0', +REQUIRES = ['azure-cosmos>=3.0.0', 'botbuilder-schema>=4.0.0.a6', 'botframework-connector>=4.0.0.a6'] -TEST_REQUIRES = ['aiounittests==1.1.0'] +TEST_REQUIRES = ['aiounittests>=1.1.0'] root = os.path.abspath(os.path.dirname(__file__)) From e76ada48dcb9e2b09d02c463c61dd4d3d8522b96 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 29 Apr 2019 15:53:34 -0700 Subject: [PATCH 73/73] fixed typo in requirement installs --- libraries/botbuilder-azure/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/botbuilder-azure/setup.py b/libraries/botbuilder-azure/setup.py index 606c80a42..1edfaaab4 100644 --- a/libraries/botbuilder-azure/setup.py +++ b/libraries/botbuilder-azure/setup.py @@ -7,7 +7,7 @@ REQUIRES = ['azure-cosmos>=3.0.0', 'botbuilder-schema>=4.0.0.a6', 'botframework-connector>=4.0.0.a6'] -TEST_REQUIRES = ['aiounittests>=1.1.0'] +TEST_REQUIRES = ['aiounittest>=1.1.0'] root = os.path.abspath(os.path.dirname(__file__))