diff --git a/generators/app/templates/core/cookiecutter.json b/generators/app/templates/core/cookiecutter.json new file mode 100644 index 000000000..4a14b6ade --- /dev/null +++ b/generators/app/templates/core/cookiecutter.json @@ -0,0 +1,4 @@ +{ + "bot_name": "my_chat_bot", + "bot_description": "Demonstrate the core capabilities of the Microsoft Bot Framework" +} \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc b/generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc new file mode 100644 index 000000000..9c1c70f04 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/.pylintrc @@ -0,0 +1,498 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore= + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=missing-docstring, + too-few-public-methods, + bad-continuation, + no-self-use, + duplicate-code, + broad-except, + no-name-in-module + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[LOGGING] + +# Format style used to check logging format string. `old` means using % +# formatting, while `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package.. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[STRING] + +# This flag controls whether the implicit-str-concat-in-sequence should +# generate a warning on implicit string concatenation in sequences defined over +# several lines. +check-str-concat-over-line-jumps=no + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement. +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md b/generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md new file mode 100644 index 000000000..b6b9b925f --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/README-LUIS.md @@ -0,0 +1,216 @@ +# Setting up LUIS via CLI: + +This README contains information on how to create and deploy a LUIS application. When the bot is ready to be deployed to production, we recommend creating a LUIS Endpoint Resource for usage with your LUIS App. + +> _For instructions on how to create a LUIS Application via the LUIS portal, see these Quickstart steps:_ +> 1. _[Quickstart: Create a new app in the LUIS portal][Quickstart-create]_ +> 2. _[Quickstart: Deploy an app in the LUIS portal][Quickstart-deploy]_ + + [Quickstart-create]: https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-build-app + [Quickstart-deploy]:https://docs.microsoft.com/azure/cognitive-services/luis/get-started-portal-deploy-app + +## Table of Contents: + +- [Prerequisites](#Prerequisites) +- [Import a new LUIS Application using a local LUIS application](#Import-a-new-LUIS-Application-using-a-local-LUIS-application) +- [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#How-to-create-a-LUIS-Endpoint-resource-in-Azure-and-pair-it-with-a-LUIS-Application) + +___ + +## [Prerequisites](#Table-of-Contents): + +#### Install Azure CLI >=2.0.61: + +Visit the following page to find the correct installer for your OS: +- https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest + +#### Install LUIS CLI >=2.4.0: + +Open a CLI of your choice and type the following: + +```bash +npm i -g luis-apis@^2.4.0 +``` + +#### LUIS portal account: + +You should already have a LUIS account with either https://luis.ai, https://eu.luis.ai, or https://au.luis.ai. To determine where to create a LUIS account, consider where you will deploy your LUIS applications, and then place them in [the corresponding region][LUIS-Authoring-Regions]. + +After you've created your account, you need your [Authoring Key][LUIS-AKey] and a LUIS application ID. + + [LUIS-Authoring-Regions]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-reference-regions#luis-authoring-regions] + [LUIS-AKey]: https://docs.microsoft.com/azure/cognitive-services/luis/luis-concept-keys#authoring-key + +___ + +## [Import a new LUIS Application using a local LUIS application](#Table-of-Contents) + +### 1. Import the local LUIS application to luis.ai + +```bash +luis import application --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appName "FlightBooking" --in "./cognitiveModels/FlightBooking.json" +``` + +Outputs the following JSON: + +```json +{ + "id": "########-####-####-####-############", + "name": "FlightBooking", + "description": "A LUIS model that uses intent and entities.", + "culture": "en-us", + "usageScenario": "", + "domain": "", + "versionsCount": 1, + "createdDateTime": "2019-03-29T18:32:02Z", + "endpoints": {}, + "endpointHitsCount": 0, + "activeVersion": "0.1", + "ownerEmail": "bot@contoso.com", + "tokenizerVersion": "1.0.0" +} +``` + +For the next step, you'll need the `"id"` value for `--appId` and the `"activeVersion"` value for `--versionId`. + +### 2. Train the LUIS Application + +```bash +luis train version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --wait +``` + +### 3. Publish the LUIS Application + +```bash +luis publish version --region "LuisAppAuthoringRegion" --authoringKey "LuisAuthoringKey" --appId "LuisAppId" --versionId "LuisAppversion" --publishRegion "LuisAppPublishRegion" +``` + +> `--region` corresponds to the region you _author_ your application in. The regions available for this are "westus", "westeurope" and "australiaeast".
+> These regions correspond to the three available portals, https://luis.ai, https://eu.luis.ai, or https://au.luis.ai.
+> `--publishRegion` corresponds to the region of the endpoint you're publishing to, (e.g. "westus", "southeastasia", "westeurope", "brazilsouth").
+> See the [reference docs][Endpoint-API] for a list of available publish/endpoint regions. + + [Endpoint-API]: https://westus.dev.cognitive.microsoft.com/docs/services/5819c76f40a6350ce09de1ac/operations/5819c77140a63516d81aee78 + +Outputs the following: + +```json + { + "versionId": "0.1", + "isStaging": false, + "endpointUrl": "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/########-####-####-####-############", + "region": "westus", + "assignedEndpointKey": null, + "endpointRegion": "westus", + "failedRegions": "", + "publishedDateTime": "2019-03-29T18:40:32Z", + "directVersionPublish": false +} +``` + +To see how to create an LUIS Cognitive Service Resource in Azure, please see [the next README][README-LUIS]. This Resource should be used when you want to move your bot to production. The instructions will show you how to create and pair the resource with a LUIS Application. + + [README-LUIS]: ./README-LUIS.md + +___ + +## [How to create a LUIS Endpoint resource in Azure and pair it with a LUIS Application](#Table-of-Contents) + +### 1. Create a new LUIS Cognitive Services resource on Azure via Azure CLI + +> _Note:_
+> _If you don't have a Resource Group in your Azure subscription, you can create one through the Azure portal or through using:_ +> ```bash +> az group create --subscription "AzureSubscriptionGuid" --location "westus" --name "ResourceGroupName" +> ``` +> _To see a list of valid locations, use `az account list-locations`_ + + +```bash +# Use Azure CLI to create the LUIS Key resource on Azure +az cognitiveservices account create --kind "luis" --name "NewLuisResourceName" --sku "S0" --location "westus" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" +``` + +The command will output a response similar to the JSON below: + +```json +{ + "endpoint": "https://westus.api.cognitive.microsoft.com/luis/v2.0", + "etag": "\"########-####-####-####-############\"", + "id": "/subscriptions/########-####-####-####-############/resourceGroups/ResourceGroupName/providers/Microsoft.CognitiveServices/accounts/NewLuisResourceName", + "internalId": "################################", + "kind": "luis", + "location": "westus", + "name": "NewLuisResourceName", + "provisioningState": "Succeeded", + "resourceGroup": "ResourceGroupName", + "sku": { + "name": "S0", + "tier": null + }, + "tags": null, + "type": "Microsoft.CognitiveServices/accounts" +} +``` + + + +Take the output from the previous command and create a JSON file in the following format: + +```json +{ + "azureSubscriptionId": "00000000-0000-0000-0000-000000000000", + "resourceGroup": "ResourceGroupName", + "accountName": "NewLuisResourceName" +} +``` + +### 2. Retrieve ARM access token via Azure CLI + +```bash +az account get-access-token --subscription "AzureSubscriptionGuid" +``` + +This will return an object that looks like this: + +```json +{ + "accessToken": "eyJ0eXAiOiJKVtokentokentokentokentokeng1dCI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyIsItokenI6Ik4tbEMwbi05REFMcXdodUhZbkhRNjNHZUNYYyJ9.eyJhdWQiOiJodHRwczovL21hbmFnZW1lbnQuY29yZS53aW5kb3dzLm5ldC8iLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC83MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDcvIiwiaWF0IjoxNTUzODc3MTUwLCJuYmYiOjE1NTM4NzcxNTAsImV4cCI6MTU1Mzg4MTA1MCwiX2NsYWltX25hbWVzIjp7Imdyb3VwcyI6InNyYzEifSwiX2NsYWltX3NvdXJjZXMiOnsic3JjMSI6eyJlbmRwb2ludCI6Imh0dHBzOi8vZ3JhcGgud2luZG93cy5uZXQvNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3L3VzZXJzL2ZmZTQyM2RkLWJhM2YtNDg0Ny04NjgyLWExNTI5MDA4MjM4Ny9nZXRNZW1iZXJPYmplY3RzIn19LCJhY3IiOiIxIiwiYWlvIjoiQVZRQXEvOEtBQUFBeGVUc201NDlhVHg4RE1mMFlRVnhGZmxxOE9RSC9PODR3QktuSmRqV1FqTkkwbmxLYzB0bHJEZzMyMFZ5bWZGaVVBSFBvNUFFUTNHL0FZNDRjdk01T3M0SEt0OVJkcE5JZW9WU0dzd0kvSkk9IiwiYW1yIjpbIndpYSIsIm1mYSJdLCJhcHBpZCI6IjA0YjA3Nzk1LThkZGItNDYxYS1iYmVlLTAyZjllMWJmN2I0NiIsImFwcGlkYWNyIjoiMCIsImRldmljZWlkIjoiNDhmNDVjNjEtMTg3Zi00MjUxLTlmZWItMTllZGFkZmMwMmE3IiwiZmFtaWx5X25hbWUiOiJHdW0iLCJnaXZlbl9uYW1lIjoiU3RldmVuIiwiaXBhZGRyIjoiMTY3LjIyMC4yLjU1IiwibmFtZSI6IlN0ZXZlbiBHdW0iLCJvaWQiOiJmZmU0MjNkZC1iYTNmLTQ4NDctODY4Mi1hMTUyOTAwODIzODciLCJvbnByZW1fc2lkIjoiUy0xLTUtMjEtMjEyNzUyMTE4NC0xNjA0MDEyOTIwLTE4ODc5Mjc1MjctMjYwOTgyODUiLCJwdWlkIjoiMTAwMzdGRkVBMDQ4NjlBNyIsInJoIjoiSSIsInNjcCI6InVzZXJfaW1wZXJzb25hdGlvbiIsInN1YiI6Ik1rMGRNMWszN0U5ckJyMjhieUhZYjZLSU85LXVFQVVkZFVhNWpkSUd1Nk0iLCJ0aWQiOiI3MmY5ODhiZi04NmYxLTQxYWYtOTFhYi0yZDdjZDAxMWRiNDciLCJ1bmlxdWVfbmFtZSI6InN0Z3VtQG1pY3Jvc29mdC5jb20iLCJ1cG4iOiJzdGd1bUBtaWNyb3NvZnQuY29tIiwidXRpIjoiT2w2NGN0TXY4RVNEQzZZQWRqRUFtokenInZlciI6IjEuMCJ9.kFAsEilE0mlS1pcpqxf4rEnRKeYsehyk-gz-zJHUrE__oad3QjgDSBDPrR_ikLdweynxbj86pgG4QFaHURNCeE6SzrbaIrNKw-n9jrEtokenlosOxg_0l2g1LeEUOi5Q4gQREAU_zvSbl-RY6sAadpOgNHtGvz3Rc6FZRITfkckSLmsKAOFoh-aWC6tFKG8P52rtB0qVVRz9tovBeNqkMYL49s9ypduygbXNVwSQhm5JszeWDgrFuVFHBUP_iENCQYGQpEZf_KvjmX1Ur1F9Eh9nb4yI2gFlKncKNsQl-tokenK7-tokentokentokentokentokentokenatoken", + "expiresOn": "2200-12-31 23:59:59.999999", + "subscription": "AzureSubscriptionGuid", + "tenant": "tenant-guid", + "tokenType": "Bearer" +} +``` + +The value needed for the next step is the `"accessToken"`. + +### 3. Use `luis add appazureaccount` to pair your LUIS resource with a LUIS Application + +```bash +luis add appazureaccount --in "path/to/created/requestBody.json" --appId "LuisAppId" --authoringKey "LuisAuthoringKey" --armToken "accessToken" +``` + +If successful, it should yield a response like this: + +```json +{ + "code": "Success", + "message": "Operation Successful" +} +``` + +### 4. See the LUIS Cognitive Services' keys + +```bash +az cognitiveservices account keys list --name "NewLuisResourceName" --subscription "AzureSubscriptionGuid" -g "ResourceGroupName" +``` + +This will return an object that looks like this: + +```json +{ + "key1": "9a69####dc8f####8eb4####399f####", + "key2": "####f99e####4b1a####fb3b####6b9f" +} +``` \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/README.md b/generators/app/templates/core/{{cookiecutter.bot_name}}/README.md new file mode 100644 index 000000000..35a5eb2f1 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/README.md @@ -0,0 +1,61 @@ +# CoreBot + +Bot Framework v4 core bot sample. + +This bot has been created using [Bot Framework](https://dev.botframework.com), it shows how to: + +- Use [LUIS](https://www.luis.ai) to implement core AI capabilities +- Implement a multi-turn conversation using Dialogs +- Handle user interruptions for such things as `Help` or `Cancel` +- Prompt for and validate requests for information from the user + +## Prerequisites + +This sample **requires** prerequisites in order to run. + +### Overview + +This bot uses [LUIS](https://www.luis.ai), an AI based cognitive service, to implement language understanding. + +### Install Python 3.6 + + +### Create a LUIS Application to enable language understanding + +LUIS language model setup, training, and application configuration steps can be found [here](https://docs.microsoft.com/azure/bot-service/bot-builder-howto-v4-luis?view=azure-bot-service-4.0&tabs=cs). + +If you wish to create a LUIS application via the CLI, these steps can be found in the [README-LUIS.md](README-LUIS.md). + +## Running the sample +- Run `pip install -r requirements.txt` to install all dependencies +- Update LuisAppId, LuisAPIKey and LuisAPIHostName in `config.py` with the information retrieved from the [LUIS portal](https://www.luis.ai) +- Run `python app.py` +- Alternatively to the last command, you can set the file in an environment variable with `set FLASK_APP=app.py` in windows (`export FLASK_APP=app.py` in mac/linux) and then run `flask run --host=127.0.0.1 --port=3978` + + +## Testing the bot using Bot Framework Emulator + +[Bot Framework Emulator](https://github.com/microsoft/botframework-emulator) is a desktop application that allows bot developers to test and debug their bots on localhost or running remotely through a tunnel. + +- Install the Bot Framework Emulator version 4.3.0 or greater from [here](https://github.com/Microsoft/BotFramework-Emulator/releases) + +### Connect to the bot using Bot Framework Emulator + +- Launch Bot Framework Emulator +- Enter a Bot URL of `http://localhost:3978/api/messages` + + +## Further reading + +- [Bot Framework Documentation](https://docs.botframework.com) +- [Bot Basics](https://docs.microsoft.com/azure/bot-service/bot-builder-basics?view=azure-bot-service-4.0) +- [Dialogs](https://docs.microsoft.com/azure/bot-service/bot-builder-concept-dialog?view=azure-bot-service-4.0) +- [Gathering Input Using Prompts](https://docs.microsoft.com/azure/bot-service/bot-builder-prompts?view=azure-bot-service-4.0&tabs=csharp) +- [Activity processing](https://docs.microsoft.com/en-us/azure/bot-service/bot-builder-concept-activity-processing?view=azure-bot-service-4.0) +- [Azure Bot Service Introduction](https://docs.microsoft.com/azure/bot-service/bot-service-overview-introduction?view=azure-bot-service-4.0) +- [Azure Bot Service Documentation](https://docs.microsoft.com/azure/bot-service/?view=azure-bot-service-4.0) +- [.NET Core CLI tools](https://docs.microsoft.com/dotnet/core/tools/?tabs=netcore2x) +- [Azure CLI](https://docs.microsoft.com/cli/azure/?view=azure-cli-latest) +- [Azure Portal](https://portal.azure.com) +- [Language Understanding using LUIS](https://docs.microsoft.com/azure/cognitive-services/luis/) +- [Channels and Bot Connector Service](https://docs.microsoft.com/azure/bot-service/bot-concepts?view=azure-bot-service-4.0) \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/app.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/app.py new file mode 100644 index 000000000..d08cff888 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/app.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# pylint: disable=import-error + +""" +This sample shows how to create a bot that demonstrates the following: +- Use [LUIS](https://www.luis.ai) to implement core AI capabilities. +- Implement a multi-turn conversation using Dialogs. +- Handle user interruptions for such things as `Help` or `Cancel`. +- Prompt for and validate requests for information from the user. +""" + +import asyncio +import sys +from datetime import datetime +from types import MethodType + +from flask import Flask, request, Response +from botbuilder.core import ( + BotFrameworkAdapter, + BotFrameworkAdapterSettings, + ConversationState, + MemoryStorage, + UserState, + TurnContext +) +from botbuilder.schema import Activity, ActivityTypes + +from bots import DialogAndWelcomeBot +from dialogs import MainDialog, BookingDialog +from flight_booking_recognizer import FlightBookingRecognizer + +LOOP = asyncio.get_event_loop() +APP = Flask(__name__, instance_relative_config=True) +APP.config.from_object("config.DefaultConfig") + +SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"]) +ADAPTER = BotFrameworkAdapter(SETTINGS) + +# Create MemoryStorage, UserState and ConversationState +MEMORY = MemoryStorage() +USER_STATE = UserState(MEMORY) +CONVERSATION_STATE = ConversationState(MEMORY) +RECOGNIZER = FlightBookingRecognizer(APP.config) +BOOKING_DIALOG = BookingDialog() + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +SETTINGS = BotFrameworkAdapterSettings(APP.config["APP_ID"], APP.config["APP_PASSWORD"]) +ADAPTER = BotFrameworkAdapter(SETTINGS) + +# Catch-all for errors. +# pylint: disable=unused-argument +async def on_error(self, context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + + # Send a message to the user + await context.send_activity("The bot encounted an error or bug.") + await context.send_activity("To continue to run this bot, please fix the bot source code.") + # Send a trace activity if we're talking to the Bot Framework Emulator + if context.activity.channel_id == 'emulator': + # Create a trace activity that contains the error object + trace_activity = Activity( + label="TurnError", + name="on_turn_error Trace", + timestamp=datetime.utcnow(), + type=ActivityTypes.trace, + value=f"{error}", + value_type="https://www.botframework.com/schemas/error" + ) + # Send a trace activity, which will be displayed in Bot Framework Emulator + await context.send_activity(trace_activity) + +ADAPTER.on_turn_error = MethodType(on_error, ADAPTER) + +DIALOG = MainDialog(RECOGNIZER, BOOKING_DIALOG) +BOT = DialogAndWelcomeBot(CONVERSATION_STATE, USER_STATE, DIALOG) + + +@APP.route("/api/messages", methods=["POST"]) +def messages(): + """Main bot message handler.""" + if "application/json" in request.headers["Content-Type"]: + body = request.json + else: + return Response(status=415) + + activity = Activity().deserialize(body) + auth_header = ( + request.headers["Authorization"] if "Authorization" in request.headers else "" + ) + + try: + task = LOOP.create_task( + ADAPTER.process_activity(activity, auth_header, BOT.on_turn) + ) + LOOP.run_until_complete(task) + return Response(status=201) + except Exception as exception: + raise exception + + +if __name__ == "__main__": + try: + APP.run(debug=False, port=APP.config["PORT"]) # nosec debug + except Exception as exception: + raise exception diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py new file mode 100644 index 000000000..ca0710ff0 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/booking_details.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + + +class BookingDetails: + def __init__( + self, + destination: str = None, + origin: str = None, + travel_date: str = None, + unsupported_airports: List[str] = None, + ): + self.destination = destination + self.origin = origin + self.travel_date = travel_date + self.unsupported_airports = unsupported_airports or [] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py new file mode 100644 index 000000000..6925db302 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .dialog_bot import DialogBot +from .dialog_and_welcome_bot import DialogAndWelcomeBot + +__all__ = ["DialogBot", "DialogAndWelcomeBot"] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py new file mode 100644 index 000000000..17bb2db80 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_and_welcome_bot.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import os.path + +from typing import List +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.schema import Attachment, ChannelAccount + +from helpers import DialogHelper +from .dialog_bot import DialogBot + + +class DialogAndWelcomeBot(DialogBot): + + 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 = self.create_adaptive_card_attachment() + response = MessageFactory.attachment(welcome_card) + await turn_context.send_activity(response) + await DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) + + # Load attachment from file. + def create_adaptive_card_attachment(self): + relative_path = os.path.abspath(os.path.dirname(__file__)) + path = os.path.join(relative_path, "../cards/welcomeCard.json") + with open(path) as card_file: + card = json.load(card_file) + + return Attachment( + content_type="application/vnd.microsoft.card.adaptive", content=card + ) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py new file mode 100644 index 000000000..5f2c148aa --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/bots/dialog_bot.py @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ActivityHandler, ConversationState, UserState, TurnContext +from botbuilder.dialogs import Dialog + +from helpers import DialogHelper + + +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 + + async def on_turn(self, turn_context: TurnContext): + await super().on_turn(turn_context) + + # Save any state changes that might have occurred 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 DialogHelper.run_dialog( + self.dialog, + turn_context, + self.conversation_state.create_property("DialogState"), + ) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json b/generators/app/templates/core/{{cookiecutter.bot_name}}/cards/welcomeCard.json new file mode 100644 index 000000000..cc10cda9f --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/cards/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": "true", + "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/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/azure/bot-service/bot-builder-howto-deploy-azure?view=azure-bot-service-4.0" + } + ] +} \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json b/generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json new file mode 100644 index 000000000..f0e4b9770 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/cognitiveModels/FlightBooking.json @@ -0,0 +1,339 @@ +{ + "luis_schema_version": "3.2.0", + "versionId": "0.1", + "name": "FlightBooking", + "desc": "Luis Model for CoreBot", + "culture": "en-us", + "tokenizerVersion": "1.0.0", + "intents": [ + { + "name": "BookFlight" + }, + { + "name": "Cancel" + }, + { + "name": "GetWeather" + }, + { + "name": "None" + } + ], + "entities": [], + "composites": [ + { + "name": "From", + "children": [ + "Airport" + ], + "roles": [] + }, + { + "name": "To", + "children": [ + "Airport" + ], + "roles": [] + } + ], + "closedLists": [ + { + "name": "Airport", + "subLists": [ + { + "canonicalForm": "Paris", + "list": [ + "paris", + "cdg" + ] + }, + { + "canonicalForm": "London", + "list": [ + "london", + "lhr" + ] + }, + { + "canonicalForm": "Berlin", + "list": [ + "berlin", + "txl" + ] + }, + { + "canonicalForm": "New York", + "list": [ + "new york", + "jfk" + ] + }, + { + "canonicalForm": "Seattle", + "list": [ + "seattle", + "sea" + ] + } + ], + "roles": [] + } + ], + "patternAnyEntities": [], + "regex_entities": [], + "prebuiltEntities": [ + { + "name": "datetimeV2", + "roles": [] + } + ], + "model_features": [], + "regex_features": [], + "patterns": [], + "utterances": [ + { + "text": "book a flight", + "intent": "BookFlight", + "entities": [] + }, + { + "text": "book a flight from new york", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 19, + "endPos": 26 + } + ] + }, + { + "text": "book a flight from seattle", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 19, + "endPos": 25 + } + ] + }, + { + "text": "book a hotel in new york", + "intent": "None", + "entities": [] + }, + { + "text": "book a restaurant", + "intent": "None", + "entities": [] + }, + { + "text": "book flight from london to paris on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 17, + "endPos": 22 + }, + { + "entity": "To", + "startPos": 27, + "endPos": 31 + } + ] + }, + { + "text": "book flight to berlin on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 15, + "endPos": 20 + } + ] + }, + { + "text": "book me a flight from london to paris", + "intent": "BookFlight", + "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": "find an airport near me", + "intent": "None", + "entities": [] + }, + { + "text": "flight to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "flight to paris from london on feb 14th", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + }, + { + "entity": "From", + "startPos": 21, + "endPos": 26 + } + ] + }, + { + "text": "fly from berlin to paris on may 5th", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 9, + "endPos": 14 + }, + { + "entity": "To", + "startPos": 19, + "endPos": 23 + } + ] + }, + { + "text": "go to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 6, + "endPos": 10 + } + ] + }, + { + "text": "going from paris to berlin", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 11, + "endPos": 15 + }, + { + "entity": "To", + "startPos": 20, + "endPos": 25 + } + ] + }, + { + "text": "i'd like to rent a car", + "intent": "None", + "entities": [] + }, + { + "text": "ignore", + "intent": "Cancel", + "entities": [] + }, + { + "text": "travel from new york to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "From", + "startPos": 12, + "endPos": 19 + }, + { + "entity": "To", + "startPos": 24, + "endPos": 28 + } + ] + }, + { + "text": "travel to new york", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 17 + } + ] + }, + { + "text": "travel to paris", + "intent": "BookFlight", + "entities": [ + { + "entity": "To", + "startPos": 10, + "endPos": 14 + } + ] + }, + { + "text": "what's the forecast for this friday?", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like for tomorrow", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like in new york", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "what's the weather like?", + "intent": "GetWeather", + "entities": [] + }, + { + "text": "winter is coming", + "intent": "None", + "entities": [] + } + ], + "settings": [] +} \ No newline at end of file diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/config.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/config.py new file mode 100644 index 000000000..8df9f92c8 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/config.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +class DefaultConfig: + """ Bot Configuration """ + + PORT = 3978 + APP_ID = os.environ.get("MicrosoftAppId", "") + APP_PASSWORD = os.environ.get("MicrosoftAppPassword", "") + LUIS_APP_ID = os.environ.get("LuisAppId", "") + LUIS_API_KEY = os.environ.get("LuisAPIKey", "") + # LUIS endpoint host name, ie "westus.api.cognitive.microsoft.com" + LUIS_API_HOST_NAME = os.environ.get("LuisAPIHostName", "") diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py new file mode 100644 index 000000000..567539f96 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +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"] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py new file mode 100644 index 000000000..c5912075d --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/booking_dialog.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import WaterfallDialog, WaterfallStepContext, DialogTurnResult +from botbuilder.dialogs.prompts import ConfirmPrompt, TextPrompt, PromptOptions +from botbuilder.core import MessageFactory +from botbuilder.schema import InputHints + +from datatypes_date_time.timex import Timex + +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.destination_step, + self.origin_step, + self.travel_date_step, + self.confirm_step, + self.final_step, + ], + ) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + + async def destination_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + """ + If a destination city has not been provided, prompt for one. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + if booking_details.destination is None: + message_text = "Where would you like to travel to?" + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + return await step_context.next(booking_details.destination) + + async def origin_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + """ + If an origin city has not been provided, prompt for one. + :param step_context: + :return 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: + message_text = "From what city will you be travelling?" + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + return await step_context.next(booking_details.origin) + + async def travel_date_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + """ + If a travel date has not been provided, prompt for one. + This will use the DATE_RESOLVER_DIALOG. + :param step_context: + :return 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.travel_date + ): + return await step_context.begin_dialog( + DateResolverDialog.__name__, booking_details.travel_date + ) + return await step_context.next(booking_details.travel_date) + + async def confirm_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + """ + Confirm the information the user has provided. + :param step_context: + :return DialogTurnResult: + """ + booking_details = step_context.options + + # Capture the results of the previous step + booking_details.travel_date = step_context.result + message_text = ( + f"Please confirm, I have you traveling to: { booking_details.destination } from: " + f"{ booking_details.origin } on: { booking_details.travel_date}." + ) + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + + # Offer a YES/NO prompt. + return await step_context.prompt( + ConfirmPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + + async def final_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + """ + Complete the interaction and end the dialog. + :param step_context: + :return DialogTurnResult: + """ + if step_context.result: + booking_details = step_context.options + + return await step_context.end_dialog(booking_details) + return await step_context.end_dialog() + + def is_ambiguous(self, timex: str) -> bool: + timex_property = Timex(timex) + return "definite" not in timex_property.types diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py new file mode 100644 index 000000000..f09a63b62 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/cancel_and_help_dialog.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + DialogContext, + DialogTurnResult, + DialogTurnStatus, +) +from botbuilder.schema import ActivityTypes, InputHints +from botbuilder.core import MessageFactory + + +class CancelAndHelpDialog(ComponentDialog): + 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() + + help_message_text = "Show Help..." + help_message = MessageFactory.text( + help_message_text, help_message_text, InputHints.expecting_input + ) + + if text in ("help", "?"): + await inner_dc.context.send_activity(help_message) + return DialogTurnResult(DialogTurnStatus.Waiting) + + cancel_message_text = "Cancelling" + cancel_message = MessageFactory.text( + cancel_message_text, cancel_message_text, InputHints.ignoring_input + ) + + if text in ("cancel", "quit"): + await inner_dc.context.send_activity(cancel_message) + return await inner_dc.cancel_all_dialogs() + + return None diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py new file mode 100644 index 000000000..985dbf389 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/date_resolver_dialog.py @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import MessageFactory +from botbuilder.dialogs import WaterfallDialog, DialogTurnResult, WaterfallStepContext +from botbuilder.dialogs.prompts import ( + DateTimePrompt, + PromptValidatorContext, + PromptOptions, + DateTimeResolution, +) +from botbuilder.schema import InputHints +from datatypes_date_time.timex import Timex + +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__ + "2", [self.initial_step, self.final_step] + ) + ) + + self.initial_dialog_id = WaterfallDialog.__name__ + "2" + + async def initial_step( + self, step_context: WaterfallStepContext + ) -> DialogTurnResult: + timex = step_context.options + + prompt_msg_text = "On what date would you like to travel?" + prompt_msg = MessageFactory.text( + prompt_msg_text, prompt_msg_text, InputHints.expecting_input + ) + + reprompt_msg_text = "I'm sorry, for best results, please enter your travel date " \ + "including the month, day and year." + reprompt_msg = MessageFactory.text( + reprompt_msg_text, reprompt_msg_text, InputHints.expecting_input + ) + + 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=prompt_msg, retry_prompt=reprompt_msg), + ) + # We have a Date we just need to check it is unambiguous. + if "definite" not in Timex(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) + ) + + return await step_context.next(DateTimeResolution(timex=timex)) + + async def final_step(self, step_context: WaterfallStepContext): + timex = step_context.result[0].timex + return await step_context.end_dialog(timex) + + @staticmethod + async def datetime_prompt_validator(prompt_context: PromptValidatorContext) -> bool: + if prompt_context.recognized.succeeded: + timex = prompt_context.recognized.value[0].timex.split("T")[0] + + return "definite" in Timex(timex).types + + return False diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py new file mode 100644 index 000000000..91566728d --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/dialogs/main_dialog.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import ( + ComponentDialog, + WaterfallDialog, + WaterfallStepContext, + DialogTurnResult, +) +from botbuilder.dialogs.prompts import TextPrompt, PromptOptions +from botbuilder.core import MessageFactory, TurnContext +from botbuilder.schema import InputHints + +from booking_details import BookingDetails +from flight_booking_recognizer import FlightBookingRecognizer + +from helpers import LuisHelper, Intent +from .booking_dialog import BookingDialog + + +class MainDialog(ComponentDialog): + def __init__( + self, luis_recognizer: FlightBookingRecognizer, booking_dialog: BookingDialog + ): + super(MainDialog, self).__init__(MainDialog.__name__) + + self._luis_recognizer = luis_recognizer + self._booking_dialog_id = booking_dialog.id + + self.add_dialog(TextPrompt(TextPrompt.__name__)) + self.add_dialog(booking_dialog) + self.add_dialog( + WaterfallDialog( + "WFDialog", [self.intro_step, self.act_step, self.final_step] + ) + ) + + self.initial_dialog_id = "WFDialog" + + async def intro_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if not self._luis_recognizer.is_configured: + 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.", + input_hint=InputHints.ignoring_input, + ) + ) + + return await step_context.next(None) + message_text = ( + str(step_context.options) + if step_context.options + else "What can I help you with today?" + ) + prompt_message = MessageFactory.text( + message_text, message_text, InputHints.expecting_input + ) + + return await step_context.prompt( + TextPrompt.__name__, PromptOptions(prompt=prompt_message) + ) + + async def act_step(self, step_context: WaterfallStepContext) -> DialogTurnResult: + if not self._luis_recognizer.is_configured: + # LUIS is not configured, we just run the BookingDialog path with an empty BookingDetailsInstance. + return await step_context.begin_dialog( + self._booking_dialog_id, BookingDetails() + ) + + # Call LUIS and gather any potential booking details. (Note the TurnContext has the response to the prompt.) + intent, luis_result = await LuisHelper.execute_luis_query( + self._luis_recognizer, step_context.context + ) + + if intent == Intent.BOOK_FLIGHT.value and luis_result: + # Show a warning for Origin and Destination if we can't resolve them. + await MainDialog._show_warning_for_unsupported_cities( + step_context.context, luis_result + ) + + # Run the BookingDialog giving it whatever details we have from the LUIS call. + return await step_context.begin_dialog(self._booking_dialog_id, luis_result) + + if intent == Intent.GET_WEATHER.value: + get_weather_text = "TODO: get weather flow here" + get_weather_message = MessageFactory.text( + get_weather_text, get_weather_text, InputHints.ignoring_input + ) + await step_context.context.send_activity(get_weather_message) + + else: + didnt_understand_text = ( + "Sorry, I didn't get that. Please try asking in a different way" + ) + didnt_understand_message = MessageFactory.text( + didnt_understand_text, didnt_understand_text, InputHints.ignoring_input + ) + await step_context.context.send_activity(didnt_understand_message) + + return await step_context.next(None) + + 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. + # time_property = Timex(result.travel_date) + # travel_date_msg = time_property.to_natural_language(datetime.now()) + msg_txt = f"I have you booked to {result.destination} from {result.origin} on {result.travel_date}" + message = MessageFactory.text(msg_txt, msg_txt, InputHints.ignoring_input) + await step_context.context.send_activity(message) + + prompt_message = "What else can I do for you?" + return await step_context.replace_dialog(self.id, prompt_message) + + @staticmethod + async def _show_warning_for_unsupported_cities( + context: TurnContext, luis_result: BookingDetails + ) -> None: + if luis_result.unsupported_airports: + message_text = ( + f"Sorry but the following airports are not supported:" + f" {', '.join(luis_result.unsupported_airports)}" + ) + message = MessageFactory.text( + message_text, message_text, InputHints.ignoring_input + ) + await context.send_activity(message) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py new file mode 100644 index 000000000..7476103c7 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/flight_booking_recognizer.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.ai.luis import LuisApplication, LuisRecognizer +from botbuilder.core import Recognizer, RecognizerResult, TurnContext + + +class FlightBookingRecognizer(Recognizer): + def __init__(self, configuration: dict): + self._recognizer = None + + luis_is_configured = ( + configuration["LUIS_APP_ID"] + and configuration["LUIS_API_KEY"] + and configuration["LUIS_API_HOST_NAME"] + ) + if luis_is_configured: + luis_application = LuisApplication( + configuration["LUIS_APP_ID"], + configuration["LUIS_API_KEY"], + "https://" + configuration["LUIS_API_HOST_NAME"], + ) + + self._recognizer = LuisRecognizer(luis_application) + + @property + def is_configured(self) -> bool: + # Returns true if luis is configured in the appsettings.json and initialized. + return self._recognizer is not None + + async def recognize(self, turn_context: TurnContext) -> RecognizerResult: + return await self._recognizer.recognize(turn_context) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py new file mode 100644 index 000000000..787a8ed1a --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .luis_helper import Intent, LuisHelper +from .dialog_helper import DialogHelper + +__all__ = [ + "DialogHelper", + "LuisHelper", + "Intent" +] diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py new file mode 100644 index 000000000..6b2646b0b --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/dialog_helper.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +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) diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py new file mode 100644 index 000000000..30331a0d5 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/helpers/luis_helper.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from enum import Enum +from typing import Dict +from botbuilder.ai.luis import LuisRecognizer +from botbuilder.core import IntentScore, TopIntent, TurnContext + +from booking_details import BookingDetails + + +class Intent(Enum): + BOOK_FLIGHT = "BookFlight" + CANCEL = "Cancel" + GET_WEATHER = "GetWeather" + NONE_INTENT = "NoneIntent" + + +def top_intent(intents: Dict[Intent, dict]) -> TopIntent: + max_intent = Intent.NONE_INTENT + max_value = 0.0 + + for intent, value in intents: + intent_score = IntentScore(value) + if intent_score.score > max_value: + max_intent, max_value = intent, intent_score.score + + return TopIntent(max_intent, max_value) + + +class LuisHelper: + @staticmethod + async def execute_luis_query( + luis_recognizer: LuisRecognizer, turn_context: TurnContext + ) -> (Intent, object): + """ + Returns an object with pre-formatted LUIS results for the bot's dialogs to consume. + """ + result = None + intent = None + + try: + recognizer_result = await luis_recognizer.recognize(turn_context) + + intent = ( + sorted( + recognizer_result.intents, + key=recognizer_result.intents.get, + reverse=True, + )[:1][0] + if recognizer_result.intents + else None + ) + + if intent == Intent.BOOK_FLIGHT.value: + result = BookingDetails() + + # We need to get the result from the LUIS JSON which at every level + # returns an array. + to_entities = recognizer_result.entities.get("$instance", {}).get( + "To", [] + ) + if len(to_entities) > 0: + if recognizer_result.entities.get("To", [{"$instance": {}}])[0][ + "$instance" + ]: + result.destination = to_entities[0]["text"].capitalize() + else: + result.unsupported_airports.append( + to_entities[0]["text"].capitalize() + ) + + from_entities = recognizer_result.entities.get("$instance", {}).get( + "From", [] + ) + if len(from_entities) > 0: + if recognizer_result.entities.get("From", [{"$instance": {}}])[0][ + "$instance" + ]: + result.origin = from_entities[0]["text"].capitalize() + else: + result.unsupported_airports.append( + from_entities[0]["text"].capitalize() + ) + + # 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. + date_entities = recognizer_result.entities.get("datetime", []) + if date_entities: + timex = date_entities[0]["timex"] + + if timex: + datetime = timex[0].split("T")[0] + + result.travel_date = datetime + + else: + result.travel_date = None + + except Exception as err: + print(err) + + return intent, result diff --git a/generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt b/generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt new file mode 100644 index 000000000..c11eb2923 --- /dev/null +++ b/generators/app/templates/core/{{cookiecutter.bot_name}}/requirements.txt @@ -0,0 +1,5 @@ +botbuilder-dialogs>=4.4.0.b1 +botbuilder-ai>=4.4.0.b1 +datatypes-date-time>=1.0.0.a2 +flask>=1.0.3 + diff --git a/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc b/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc index a77268c79..1baee5edb 100644 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc +++ b/generators/app/templates/echo/{{cookiecutter.bot_name}}/.pylintrc @@ -62,7 +62,10 @@ confidence= # --disable=W". disable=missing-docstring, too-few-public-methods, - bad-continuation + bad-continuation, + no-self-use, + duplicate-code, + broad-except # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py b/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py index be9b70499..f7fa35cac 100644 --- a/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py +++ b/generators/app/templates/echo/{{cookiecutter.bot_name}}/app.py @@ -26,6 +26,7 @@ ADAPTER = BotFrameworkAdapter(SETTINGS) # Catch-all for errors. +# pylint: disable=unused-argument async def on_error(self, context: TurnContext, error: Exception): # This check writes out errors to console log .vs. app insights. # NOTE: In production environment, you should consider logging this to Azure diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc b/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc index a77268c79..1baee5edb 100644 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc +++ b/generators/app/templates/empty/{{cookiecutter.bot_name}}/.pylintrc @@ -62,7 +62,10 @@ confidence= # --disable=W". disable=missing-docstring, too-few-public-methods, - bad-continuation + bad-continuation, + no-self-use, + duplicate-code, + broad-except # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py b/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py index 5dfdc30f1..4ab9d480f 100644 --- a/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py +++ b/generators/app/templates/empty/{{cookiecutter.bot_name}}/app.py @@ -25,6 +25,7 @@ ADAPTER = BotFrameworkAdapter(SETTINGS) # Catch-all for errors. +# pylint: disable=unused-argument async def on_error(self, context: TurnContext, error: Exception): # This check writes out errors to console log .vs. app insights. # NOTE: In production environment, you should consider logging this to Azure