├── tests ├── __init__.py ├── test_samples.py ├── helpers.py ├── test_context_manager.py ├── test_assistant.py └── conftest.py ├── samples ├── actions_demo │ ├── __init__.py │ ├── api_agent.zip │ ├── templates │ │ ├── entities.yaml │ │ └── user_says.yaml │ └── webhook.py ├── reservation │ ├── __init__.py │ └── agent.py ├── pizza_contexts │ ├── __init__.py │ └── agent.py ├── hello_world │ ├── templates │ │ ├── entities.yaml │ │ └── user_says.yaml │ └── webhook.py ├── hass_integration │ ├── templates │ │ ├── entities.yaml │ │ └── user_says.yaml │ └── webhook.py └── account_linking │ └── webhook.py ├── docs ├── source │ ├── indices.rst │ ├── _static │ │ ├── IMG_4270.png │ │ ├── logo-sm.png │ │ ├── logo-xs.png │ │ └── IMG_4270 (1).png │ ├── contents.rst.inc │ ├── _themes │ │ ├── flask │ │ │ ├── theme.conf │ │ │ ├── relations.html │ │ │ ├── layout.html │ │ │ └── static │ │ │ │ └── flasky.css_t │ │ ├── README │ │ ├── LICENSE │ │ └── flask_theme_support.py │ ├── flaskdocext.py │ ├── _templates │ │ └── stayinformed.html │ ├── code.rst │ ├── index.rst │ ├── parameters.rst │ ├── generate_schema.rst │ ├── hass.rst │ ├── conf.py │ ├── contexts.rst │ ├── responses.rst │ └── quick_start.rst └── Makefile ├── Makefile ├── flask_assistant ├── response │ ├── __init__.py │ ├── dialogflow.py │ ├── actions.py │ ├── hangouts.py │ ├── df_messenger.py │ └── base.py ├── __init__.py ├── utils.py ├── manager.py └── hass.py ├── api_ai ├── __init__.py ├── cli.py ├── api.py ├── models.py └── schema_handlers.py ├── setup.cfg ├── Pipfile ├── requirements.txt ├── .gitlab-ci.yml ├── setup.py ├── README.md ├── .gitignore └── LICENSE.txt /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /samples/actions_demo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /samples/reservation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /samples/pizza_contexts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/indices.rst: -------------------------------------------------------------------------------- 1 | Indices 2 | ================== 3 | 4 | * :ref:`genindex` 5 | * :ref:`modindex` 6 | * :ref:`search` -------------------------------------------------------------------------------- /docs/source/_static/IMG_4270.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treethought/flask-assistant/HEAD/docs/source/_static/IMG_4270.png -------------------------------------------------------------------------------- /docs/source/_static/logo-sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treethought/flask-assistant/HEAD/docs/source/_static/logo-sm.png -------------------------------------------------------------------------------- /docs/source/_static/logo-xs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treethought/flask-assistant/HEAD/docs/source/_static/logo-xs.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | pip install pipenv --upgrade 3 | pipenv install --dev --skip-lock 4 | test: 5 | pipenv run pytest tests/ 6 | -------------------------------------------------------------------------------- /samples/actions_demo/api_agent.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treethought/flask-assistant/HEAD/samples/actions_demo/api_agent.zip -------------------------------------------------------------------------------- /docs/source/_static/IMG_4270 (1).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treethought/flask-assistant/HEAD/docs/source/_static/IMG_4270 (1).png -------------------------------------------------------------------------------- /flask_assistant/response/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import _Response, _ListSelector, ask, tell, event, permission 2 | from .base import * 3 | -------------------------------------------------------------------------------- /api_ai/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger('api_ai') 4 | logger.addHandler(logging.StreamHandler()) 5 | if logger.level == logging.NOTSET: 6 | logger.setLevel(logging.INFO) -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_wheel] 5 | universal=1 6 | 7 | [aliases] 8 | test=pytest 9 | 10 | [tool:pytest] 11 | addopts = --verbose 12 | testpaths = tests 13 | -------------------------------------------------------------------------------- /docs/source/contents.rst.inc: -------------------------------------------------------------------------------- 1 | Table Of Contents 2 | ----------------- 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | 8 | quick_start 9 | responses 10 | parameters 11 | contexts 12 | generate_schema 13 | hass 14 | 15 | -------------------------------------------------------------------------------- /docs/source/_themes/flask/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | pygments_style = flask_theme_support.FlaskyStyle 5 | 6 | [options] 7 | index_logo = 'logo-xs.png' 8 | index_logo_height = 120px 9 | touch_icon = 10 | github_fork = 'treethought/flask-assistant' 11 | -------------------------------------------------------------------------------- /samples/actions_demo/templates/entities.yaml: -------------------------------------------------------------------------------- 1 | # Template file for entities 2 | 3 | #Format as below 4 | 5 | # entity_name: 6 | # - entry1: list of synonyms 7 | # - entry2: list of synonyms 8 | 9 | #For example: 10 | 11 | # drink: 12 | # - water: ['aqua', 'h20'] 13 | # - coffee: ['joe', 'caffeine', 'espresso', 'late'] 14 | # - soda: ['pop', 'coke'] 15 | 16 | 17 | 18 | {} 19 | -------------------------------------------------------------------------------- /docs/source/flaskdocext.py: -------------------------------------------------------------------------------- 1 | import re 2 | import inspect 3 | 4 | 5 | _internal_mark_re = re.compile(r'^\s*:internal:\s*$(?m)') 6 | 7 | 8 | def skip_member(app, what, name, obj, skip, options): 9 | docstring = inspect.getdoc(obj) 10 | if skip: 11 | return True 12 | return _internal_mark_re.search(docstring or '') is not None 13 | 14 | 15 | def setup(app): 16 | app.connect('autodoc-skip-member', skip_member) 17 | -------------------------------------------------------------------------------- /samples/hello_world/templates/entities.yaml: -------------------------------------------------------------------------------- 1 | # Template file for entities 2 | 3 | #Format as below 4 | 5 | # entity_name: 6 | # - entry1: list of synonyms 7 | # - entry2: list of synonyms 8 | 9 | #For example: 10 | 11 | # drink: 12 | # - water: ['aqua', 'h20'] 13 | # - coffee: ['joe', 'caffeine', 'espresso', 'late'] 14 | # - soda: ['pop', 'coke'] 15 | 16 | 17 | 18 | gender: 19 | - male: ['man', 'boy', 'guy', 'dude'] 20 | - female: ['woman', 'girl', 'gal'] 21 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pytest = "*" 8 | pytest-cov = "*" 9 | tox = "*" 10 | flask-assistant = {editable = true,path = "."} 11 | 12 | [packages] 13 | aniso8601 = "*" 14 | certifi = "*" 15 | chardet = "*" 16 | click = "*" 17 | idna = "*" 18 | itsdangerous = "*" 19 | requests = "*" 20 | urllib3 = "*" 21 | Flask = ">1.0.2" 22 | MarkupSafe = "*" 23 | "ruamel.yaml" = "*" 24 | Werkzeug = "*" 25 | google-auth = "*" 26 | google-cloud-dialogflow = "*" 27 | 28 | [requires] 29 | python_version = "3.7" 30 | 31 | [pipenv] 32 | allow_prereleases = true 33 | -------------------------------------------------------------------------------- /docs/source/_themes/flask/relations.html: -------------------------------------------------------------------------------- 1 |

Related Topics

2 | 20 | -------------------------------------------------------------------------------- /flask_assistant/response/dialogflow.py: -------------------------------------------------------------------------------- 1 | from google.cloud import dialogflow_v2 as df 2 | from google.protobuf.json_format import MessageToDict 3 | 4 | 5 | def build_card( 6 | text, title, img_url=None, img_alt=None, subtitle=None, link=None, link_title=None, 7 | ): 8 | 9 | button = df.Intent.Message.Card.Button( 10 | text=link_title, postback=link 11 | ) 12 | card = df.Intent.Message.Card(title=title, subtitle=text) 13 | card.buttons.append(button) 14 | payload = df.Intent.Message.Card.to_dict(card) 15 | return {"card": payload, "lang": "en"} 16 | # card_payload = {"card": {"title": title, "subtitle": text, "formattedText": text}} 17 | # return card_payload 18 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Flask-Assistant 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | 22 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | aniso8601==8.1.0 3 | cachetools==4.2.0 4 | certifi==2020.12.5 5 | chardet==4.0.0 6 | click==8.0.0a1 7 | google-cloud-dialogflow==2.9.1 8 | flask==1.1.2 9 | google-api-core[grpc]==1.28.0 10 | google-auth==1.25.0 11 | googleapis-common-protos==1.52.0 12 | grpcio==1.34.0 13 | idna==2.10 14 | itsdangerous==2.0.0a1 15 | jinja2==3.0.0a1 16 | markupsafe==2.0.0a1 17 | protobuf==4.0.0rc2 18 | pyasn1-modules==0.2.8 19 | pyasn1==0.4.8 20 | pytz==2020.5 21 | requests==2.25.1 22 | rsa==4.7 ; python_version >= '3.6' 23 | ruamel.yaml.clib==0.2.2 ; platform_python_implementation == 'CPython' and python_version < '3.9' 24 | ruamel.yaml==0.16.12 25 | six==1.15.0 26 | urllib3==1.26.2 27 | werkzeug==1.0.1 28 | -------------------------------------------------------------------------------- /samples/actions_demo/templates/user_says.yaml: -------------------------------------------------------------------------------- 1 | # Template for defining UserSays examples 2 | 3 | # give-color-intent: 4 | 5 | # UserSays: 6 | # - My color is blue 7 | # - red is my favorite color 8 | 9 | # Annotations: 10 | # - blue: sys.color # maps param value -> entity 11 | # - red: sys.color 12 | 13 | 14 | ShowCard: 15 | UserSays: 16 | - card 17 | - show card 18 | 19 | ShowList: 20 | UserSays: 21 | - list 22 | - show list 23 | 24 | FlaskAssistantCarousel: 25 | UserSays: 26 | - flask_assistant # key from list 27 | - Flask-Assistant 28 | 29 | FlaskAskCard: 30 | UserSays: 31 | - flask_ask # key from list 32 | - Flask-Ask 33 | 34 | FlaskCard: 35 | UserSays: 36 | - flask # key from list 37 | - Flask 38 | -------------------------------------------------------------------------------- /flask_assistant/response/actions.py: -------------------------------------------------------------------------------- 1 | def build_card( 2 | text, 3 | title, 4 | img_url=None, 5 | img_alt=None, 6 | subtitle=None, 7 | link=None, 8 | link_title=None, 9 | buttons=None, 10 | ): 11 | 12 | card_payload = {"title": title, "subtitle": subtitle, "formattedText": text} 13 | 14 | if buttons: 15 | card_payload["buttons"] = buttons 16 | 17 | elif link and link_title: 18 | btn_payload = [{"title": link_title, "openUriAction": {"uri": link}}] 19 | card_payload["buttons"] = btn_payload 20 | 21 | if img_url: 22 | img_payload = {"imageUri": img_url, "accessibilityText": img_alt or img_url} 23 | card_payload["image"] = img_payload 24 | 25 | return {"platform": "ACTIONS_ON_GOOGLE", "basicCard": card_payload} 26 | -------------------------------------------------------------------------------- /flask_assistant/response/hangouts.py: -------------------------------------------------------------------------------- 1 | from google.cloud import dialogflow_v2 as df 2 | import logging 3 | 4 | 5 | def build_card( 6 | text, title, img_url=None, img_alt=None, subtitle=None, link=None, link_title=None, 7 | ): 8 | if link is None: 9 | logging.warning( 10 | "Hangouts Chat card will not render properly without a button link" 11 | ) 12 | link = link_title 13 | 14 | if link_title is None: 15 | link_title = "Learn More" 16 | 17 | button = df.Intent.Message.Card.Button( 18 | text=link_title, postback=link 19 | ) 20 | card = df.Intent.Message.Card(title=title, subtitle=text) 21 | card.buttons.append(button) 22 | 23 | payload = df.Intent.Message.Card.to_dict(card) 24 | return {"card": payload, "platform": "GOOGLE_HANGOUTS", "lang": "en"} 25 | -------------------------------------------------------------------------------- /samples/hello_world/templates/user_says.yaml: -------------------------------------------------------------------------------- 1 | # Template for defining UserSays examples 2 | 3 | # give-color-intent: 4 | 5 | # UserSays: 6 | # - My color is blue 7 | # - red is my favorite color 8 | 9 | # Annotations: 10 | # - blue: sys.color # maps param value -> entity 11 | # - red: sys.color 12 | 13 | 14 | 15 | give-color: 16 | 17 | UserSays: 18 | - my color is blue 19 | - Its blue 20 | - I like red 21 | - My favorite color is red 22 | - blue 23 | 24 | Annotations: 25 | - blue: sys.color 26 | - red: sys.color 27 | 28 | greeting: 29 | UserSays: 30 | - hi 31 | - hello 32 | - start 33 | - begin 34 | - launch 35 | 36 | give-gender: 37 | UserSays: 38 | - male 39 | - Im a female 40 | - girl 41 | 42 | Annotations: 43 | - male: gender 44 | - female: gender 45 | - girl: gender -------------------------------------------------------------------------------- /docs/source/_templates/stayinformed.html: -------------------------------------------------------------------------------- 1 | 2 |

Stay Informed

3 | 4 | Star 5 | 6 |
7 | 8 |

9 | Receive updates on new releases and upcoming projects. 10 |

11 | 12 |

13 | Follow @treethought 14 |

15 | 16 |

17 | Follow @_johnwheeler 18 |

19 | 20 |
-------------------------------------------------------------------------------- /docs/source/code.rst: -------------------------------------------------------------------------------- 1 | Documentation for the Code 2 | ************************** 3 | 4 | Assistant 5 | ============================== 6 | Central interface for the assistant app 7 | 8 | .. autoclass:: flask_assistant.Assistant 9 | :members: 10 | 11 | 12 | 13 | ContextManager 14 | =================================== 15 | 16 | Used for storing and accessing contexts and their parameters 17 | 18 | .. autoclass:: flask_assistant.manager.ContextManager 19 | :members: 20 | 21 | Responses 22 | ====================== 23 | 24 | .. autoclass:: flask_assistant.response.ask 25 | :members: 26 | 27 | .. autoclass:: flask_assistant.response.tell 28 | :members: 29 | 30 | .. autoclass:: flask_assistant.response.event 31 | :members: 32 | 33 | .. autoclass:: flask_assistant.response._Response 34 | :members: 35 | 36 | 37 | 38 | 39 | 40 | 41 | Rich Messages 42 | ------------- 43 | 44 | -------------------------------------------------------------------------------- /flask_assistant/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger("flask_assistant") 4 | handler = logging.StreamHandler() 5 | formatter = logging.Formatter( 6 | "%(asctime)s:%(name)s:%(levelname)s: %(message)s", "%Y-%m-%d %H:%M:%S" 7 | ) 8 | handler.setFormatter(formatter) 9 | logger.addHandler(handler) 10 | 11 | 12 | if logger.level == logging.NOTSET: 13 | logger.setLevel(logging.INFO) 14 | 15 | 16 | from flask_assistant.core import ( 17 | Assistant, 18 | context_manager, 19 | intent, 20 | request, 21 | access_token, 22 | user, 23 | storage, 24 | session_id, 25 | context_in, 26 | profile, 27 | ) 28 | 29 | from flask_assistant.response import ( 30 | ask, 31 | tell, 32 | event, 33 | build_item, 34 | permission, 35 | sign_in, 36 | build_button, 37 | ) 38 | 39 | from flask_assistant.manager import Context 40 | 41 | import flask_assistant.utils 42 | 43 | from api_ai.api import ApiAi 44 | -------------------------------------------------------------------------------- /samples/hass_integration/templates/entities.yaml: -------------------------------------------------------------------------------- 1 | # Template file for entities 2 | 3 | #Format as below 4 | 5 | # entity_name: 6 | # - entry1: list of synonyms 7 | # - entry2: list of synonyms 8 | 9 | #For example: 10 | 11 | # drink: 12 | # - water: ['aqua', 'h20'] 13 | # - coffee: ['joe', 'caffeine', 'espresso', 'late'] 14 | # - soda: ['pop', 'coke'] 15 | 16 | 17 | 18 | light: 19 | - hue_white_lamp_1: ['fan', 'fan light', 'main light'] 20 | - hue_white_lamp_2: ['lamp', 'desk lamp'] 21 | - room: ['all lights', 'lights', 'room'] # a group within home assistant 22 | 23 | 24 | script: 25 | - flash_lights: ['flash', 'flash lights', 'strobe'] 26 | 27 | brightness: 28 | - 100 29 | - 50 30 | - 255 31 | 32 | switch: 33 | - playstation4: ['playstation', 'ps4'] 34 | 35 | shell_command: 36 | - playstationvue_start: [vue] 37 | - playstation_netflix_start: [netflix] 38 | - playstation_overwatch_start: [overwatch] 39 | - playstation_gtav_start: [gta five, gta] 40 | - playstation_hulu_start: [hulu] 41 | 42 | -------------------------------------------------------------------------------- /samples/account_linking/webhook.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_assistant import Assistant, ask, profile, sign_in 3 | 4 | 5 | app = Flask(__name__) 6 | 7 | app.config['INTEGRATIONS'] = ['ACTIONS_ON_GOOGLE'] 8 | app.config['AOG_CLIENT_ID'] = "CLIENT_ID OBTAINED BY SETTING UP ACCOUNT LINKING IN AOG CONSOLE" 9 | 10 | 11 | assist = Assistant(app=app, route="/", project_id="YOUR_GCP_PROJECT_ID") 12 | 13 | @assist.action("Default Welcome Intent") 14 | def welcome(): 15 | if profile: 16 | return ask(f"Welcome back {profile['name']}") 17 | 18 | return sign_in("To learn more about you") 19 | 20 | # this intent must have the actions_intent_SIGN_IN event 21 | # and will be invoked once the user has 22 | @assist.action("Complete-Sign-In") 23 | def complete_sign_in(): 24 | if profile: 25 | return ask(f"Welcome aboard {profile['name']}, thanks for signing up!") 26 | else: 27 | return ask("Hope you sign up soon! Would love to get to know you!") 28 | 29 | 30 | if __name__ == "__main__": 31 | app.run(debug=True) 32 | 33 | -------------------------------------------------------------------------------- /tests/test_samples.py: -------------------------------------------------------------------------------- 1 | from flask_assistant.utils import get_assistant 2 | from tests.helpers import build_payload, get_query_response 3 | 4 | 5 | def test_hello_world_greeting(hello_world_assist): 6 | client = hello_world_assist.app.test_client() 7 | payload = build_payload("greeting") 8 | resp = get_query_response(client, payload) 9 | assert "male or female" in resp["fulfillmentText"] 10 | 11 | 12 | def test_hello_world_give_gender(hello_world_assist): 13 | client = hello_world_assist.app.test_client() 14 | payload = build_payload("give-gender", params={"gender": "male"}) 15 | resp = get_query_response(client, payload) 16 | assert "Sup bro" in resp["fulfillmentText"] 17 | assert "What is your favorite color?" in resp["fulfillmentText"] 18 | 19 | 20 | def test_hello_world_give_color(hello_world_assist): 21 | client = hello_world_assist.app.test_client() 22 | payload = build_payload("give-color", params={"color": "blue"}) 23 | resp = get_query_response(client, payload) 24 | 25 | assert "Ok, blue is an okay" in resp["fulfillmentText"] 26 | -------------------------------------------------------------------------------- /docs/source/_themes/README: -------------------------------------------------------------------------------- 1 | Flask Sphinx Styles 2 | =================== 3 | 4 | This repository contains sphinx styles for Flask and Flask related 5 | projects. To use this style in your Sphinx documentation, follow 6 | this guide: 7 | 8 | 1. put this folder as _themes into your docs folder. Alternatively 9 | you can also use git submodules to check out the contents there. 10 | 2. add this to your conf.py: 11 | 12 | sys.path.append(os.path.abspath('_themes')) 13 | html_theme_path = ['_themes'] 14 | html_theme = 'flask' 15 | 16 | The following themes exist: 17 | 18 | - 'flask' - the standard flask documentation theme for large 19 | projects 20 | - 'flask_small' - small one-page theme. Intended to be used by 21 | very small addon libraries for flask. 22 | 23 | The following options exist for the flask_small theme: 24 | 25 | [options] 26 | index_logo = '' filename of a picture in _static 27 | to be used as replacement for the 28 | h1 in the index.rst file. 29 | index_logo_height = 120px height of the index logo 30 | github_fork = '' repository name on github for the 31 | "fork me" badge 32 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | - publish 4 | 5 | 6 | # base test job 7 | .test: 8 | stage: test 9 | before_script: 10 | - pip install pipenv 11 | - pipenv install --dev --skip-lock 12 | script: 13 | - pipenv run pytest tests/ 14 | 15 | only: 16 | refs: 17 | - merge_requests 18 | - master 19 | - main 20 | - external_pull_requests 21 | 22 | 23 | test:3.5: 24 | extends: .test 25 | image: python:3.5-buster 26 | 27 | 28 | test:3.6: 29 | extends: .test 30 | image: python:3.6-buster 31 | 32 | test:3.7: 33 | extends: .test 34 | image: python:3.7-buster 35 | 36 | test:3.8: 37 | extends: .test 38 | image: python:3.8-buster 39 | 40 | .publish: 41 | stage: publish 42 | image: python:3.7-buster 43 | before_script: 44 | - pip install setuptools wheel twine 45 | - python setup.py sdist bdist_wheel 46 | - twine check dist/* 47 | 48 | 49 | publish:staging: 50 | extends: .publish 51 | script: 52 | - twine upload --repository-url https://test.pypi.org/legacy/ dist/* 53 | only: 54 | refs: 55 | - /^v[0-9]+\.[0-9]+\.[0-9]-rc\.[1-9]+$/ 56 | 57 | publish:prod: 58 | extends: .publish 59 | when: manual 60 | script: 61 | - twine upload dist/* 62 | only: 63 | refs: 64 | - /^v[0-9]+\.[0-9]+\.[0-9]$/ 65 | -------------------------------------------------------------------------------- /samples/hello_world/webhook.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import Flask 3 | from flask_assistant import Assistant, ask, tell, context_manager, permission, event 4 | 5 | app = Flask(__name__) 6 | assist = Assistant(app) 7 | logging.getLogger("flask_assistant").setLevel(logging.DEBUG) 8 | 9 | app.config["INTEGRATIONS"] = ["ACTIONS_ON_GOOGLE"] 10 | 11 | 12 | @assist.action("greeting") 13 | def greet_and_start(): 14 | speech = "Hey! Are you male or female?" 15 | resp = ask(speech) 16 | resp.suggest("Male", "Female") 17 | return resp 18 | 19 | 20 | @assist.prompt_for("gender", "give-gender") 21 | def prompt_gender(gender): 22 | return ask("I need to know your gender") 23 | 24 | 25 | @assist.action("give-gender") 26 | def ask_for_color(gender): 27 | if gender.lower() == "male": 28 | gender_msg = "Sup bro!" 29 | 30 | else: 31 | gender_msg = "Haay gurl!" 32 | 33 | speech = gender_msg + " What is your favorite color?" 34 | return ask(speech) 35 | 36 | 37 | @assist.prompt_for("color", intent_name="give-color") 38 | def prompt_color(color): 39 | speech = "Sorry I didn't catch that. What color did you say?" 40 | return ask(speech) 41 | 42 | 43 | @assist.action("give-color", mapping={"color": "sys.color"}) 44 | def repeat_color(color): 45 | speech = "Ok, {} is an okay color I guess.".format(color) 46 | return ask(speech) 47 | 48 | 49 | if __name__ == "__main__": 50 | app.run(debug=True) 51 | -------------------------------------------------------------------------------- /samples/hass_integration/templates/user_says.yaml: -------------------------------------------------------------------------------- 1 | # Template for defining UserSays examples 2 | 3 | # give-color-intent: 4 | 5 | # UserSays: 6 | # - My color is blue 7 | # - red is my favorite color 8 | 9 | # Annotations: 10 | # - blue: sys.color # maps param value -> entity 11 | # - red: sys.color 12 | 13 | get-light-states: 14 | UserSays: 15 | - are the lights on? 16 | - are the lights off? 17 | - which lights are on? 18 | - whats the light status 19 | 20 | turn-on-light: 21 | UserSays: 22 | - turn on the fan 23 | - turn on the lamp to 100 percent 24 | - turn on fan to 50 brightness 25 | Annotations: 26 | - fan: light 27 | - lamp: light 28 | - lights: light 29 | - 100: brightness 30 | - 50: brightness 31 | 32 | turn-off-light: 33 | UserSays: 34 | - turn off the fan 35 | - turn off the lamp 36 | - turn off fan 37 | - turn out the lights in the room 38 | Annotations: 39 | - fan: light 40 | - lamp: light 41 | - room: light # room is a light group - light.room 42 | 43 | 44 | greeting: 45 | UserSays: 46 | - hi 47 | - hello 48 | 49 | toggle-switch: 50 | UserSays: 51 | - toggle the ps4 52 | - 53 | Annotations: 54 | - ps4: switch 55 | 56 | start-script: 57 | UserSays: 58 | - flash the lights 59 | - flash 60 | Annotations: 61 | - flash: script 62 | 63 | 64 | run-command: 65 | UserSays: 66 | - turn on ps4 67 | - start netflix 68 | - open hulu 69 | Annotations: 70 | - ps4: shell_command 71 | - netflix: shell_command 72 | - open hulu: shell_command 73 | 74 | 75 | -------------------------------------------------------------------------------- /docs/source/_themes/flask/layout.html: -------------------------------------------------------------------------------- 1 | {%- extends "basic/layout.html" %} {%- block extrahead %} {{ super() }} 2 | 3 | 4 | 5 | 23 | 24 | 25 | 26 | 27 | {% if theme_touch_icon %} 28 | {% endif %} {% endblock %} {%- block relbar2 %} {% if theme_github_fork %} 29 | 30 | Fork me on GitHub 31 | 32 | {% endif %} {% endblock %} {%- block footer %} 33 | 36 | {%- endblock %} 37 | -------------------------------------------------------------------------------- /samples/hass_integration/webhook.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import Flask 3 | from flask_assistant import Assistant, tell 4 | from flask_assistant.hass import HassRemote 5 | 6 | app = Flask(__name__) 7 | assist = Assistant(app) 8 | hass = HassRemote('Your Home Assistant Password') 9 | logging.getLogger('flask_assistant').setLevel(logging.DEBUG) 10 | 11 | 12 | 13 | @assist.action('greeting') 14 | def welcome(): 15 | speech = 'Heres an exmaple of Home Assistant integration' 16 | hass.call_service('script', 'flash_lights') 17 | return tell(speech) 18 | 19 | @assist.action('get-light-states') 20 | def light_report(): 21 | speech = '' 22 | for light in hass.light_states: 23 | speech += '{} is {} '.format(light.object_id, light.state) 24 | return tell(speech) 25 | 26 | 27 | @assist.action('turn-on-light') 28 | def turn_on_light(light, brightness=255): 29 | speech = 'Turning on {} to {}'.format(light, brightness) 30 | hass.turn_on_light(light, brightness) 31 | return tell(speech) 32 | 33 | @assist.action('turn-off-light') 34 | def turn_off_light(light): 35 | speech = 'Turning off {}'.format(light) 36 | hass.turn_off_light(light) 37 | return tell(speech) 38 | 39 | 40 | @assist.action('toggle-switch') 41 | def switch(switch): 42 | speech = 'Toggling switch for {}'.format(switch) 43 | hass.switch(switch) 44 | return tell(speech) 45 | 46 | 47 | @assist.action('start-script') 48 | def run_script(script): 49 | speech = 'Running {}'.format('script.{}'.format(script)) 50 | hass.start_script(script) 51 | return tell(speech) 52 | 53 | @assist.action('run-command') 54 | def run(shell_command): 55 | speech = 'Running the {} command shel command'.format(shell_command) 56 | hass.command(shell_command) 57 | return tell(speech) 58 | 59 | 60 | if __name__ == '__main__': 61 | app.run(debug=True) 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /flask_assistant/response/df_messenger.py: -------------------------------------------------------------------------------- 1 | def _build_info_response( 2 | text, title, img_url=None, img_alt=None, subtitle=None, link=None, link_title=None, 3 | ): 4 | 5 | info_resp = {"type": "info", "title": title, "subtitle": text} 6 | 7 | if img_url: 8 | info_resp["image"] = {"src": {"rawUrl": img_url}} 9 | 10 | if link: 11 | info_resp["actionLink"] = link 12 | 13 | return info_resp 14 | 15 | 16 | def _build_description_response(text, title): 17 | 18 | descr_resp = {"type": "description", "title": title, "text": [text]} 19 | 20 | return descr_resp 21 | 22 | 23 | def _build_button(link, link_title, icon=None, icon_color=None): 24 | btn = { 25 | "type": "button", 26 | "text": link_title, 27 | "link": link, 28 | } 29 | 30 | if icon is None: 31 | icon = "chevron_right" 32 | 33 | if icon_color is None: 34 | icon_color = "#FF9800" 35 | 36 | btn["icon"] = {"type": icon, "color": icon_color} 37 | 38 | return btn 39 | 40 | 41 | def _build_list(title, items): 42 | list_responses = [] 43 | empty_event = {"name": "", "languageCode": "", "parameters": {}} 44 | for i in items: 45 | 46 | item = { 47 | "type": "list", 48 | "title": i["title"], 49 | "subtitle": i["description"], 50 | "event": i.get("event", empty_event), 51 | } 52 | list_responses.append(item) 53 | 54 | return list_responses 55 | 56 | 57 | def _build_chip(text, img=None, url=None): 58 | c = {"text": text} 59 | if img: 60 | c["image"] = {"src": {"rawUrl": img}} 61 | 62 | if url: 63 | c["link"] = url 64 | 65 | return c 66 | 67 | 68 | def _build_suggestions(*replies): 69 | chips = {"type": "chips", "options": []} 70 | for i in replies: 71 | c = _build_chip(i) 72 | chips["options"].append(c) 73 | 74 | return chips 75 | -------------------------------------------------------------------------------- /docs/source/_themes/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 by Armin Ronacher. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms of the theme, with or 6 | without modification, are permitted provided that the following conditions 7 | are met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | We kindly ask you to only use these themes in an unmodified manner just 22 | for Flask and Flask-related products, not for unrelated projects. If you 23 | like the visual style and want to use it for your own projects, please 24 | consider making some larger changes to the themes (such as changing 25 | font faces, sizes, colors or margins). 26 | 27 | THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 28 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 29 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 30 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 31 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 32 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 33 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 34 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 35 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 36 | ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE 37 | POSSIBILITY OF SUCH DAMAGE. 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask-Assistant 3 | ------------- 4 | Framework for Building Virtual Assistants with API.AI 5 | """ 6 | from setuptools import setup 7 | 8 | with open("./README.md", "r") as f: 9 | long_description = f.read() 10 | 11 | 12 | setup( 13 | name="Flask-Assistant", 14 | version="0.5.4", 15 | url="https://github.com/treethought/flask-assistant", 16 | license="Apache 2.0", 17 | author="Cam Sweeney", 18 | author_email="cpsweene@gmail.com", 19 | description="Framework for Building Virtual Assistants with Dialogflow", 20 | long_description=long_description, 21 | long_description_content_type="text/markdown", 22 | packages=["flask_assistant", "flask_assistant.response", "api_ai"], 23 | zip_safe=False, 24 | include_package_data=True, 25 | platforms="any", 26 | install_requires=[ 27 | "Flask", 28 | "requests", 29 | "ruamel.yaml", 30 | "aniso8601", 31 | "google-auth", 32 | "google-cloud-dialogflow", 33 | ], 34 | setup_requires=["pytest-runner"], 35 | tests_require=["pytest"], 36 | test_suite="tests", 37 | extras_require={"HassRemote": ["homeassistant>=0.37.1"]}, 38 | entry_points={ 39 | "console_scripts": [ 40 | "schema=api_ai.cli:schema", 41 | "query=api_ai.cli:query", 42 | "templates=api_ai.cli:gen_templates", 43 | "entities=api_ai.cli:entities", 44 | "intents=api_ai.cli:intents", 45 | "check=api_ai.cli:check", 46 | ] 47 | }, 48 | classifiers=[ 49 | "License :: OSI Approved :: Apache Software License", 50 | "Framework :: Flask", 51 | "Programming Language :: Python", 52 | "Environment :: Web Environment", 53 | "Intended Audience :: Developers", 54 | "Operating System :: OS Independent", 55 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 56 | "Topic :: Software Development :: Libraries :: Python Modules", 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Create Virtual Assistants with Python 2 | ===================================== 3 | 4 | [![image](https://img.shields.io/pypi/v/flask-assistant.svg)](https://pypi.python.org/pypi/flask-assistant) 5 | [![image](https://travis-ci.org/treethought/flask-assistant.svg?branch=master)](https://travis-ci.org/treethought/flask-assistant) ![image](https://img.shields.io/badge/python-3.5,%203.6,%203.7-blue.svg) [![image](https://img.shields.io/badge/discord-join%20chat-green.svg)](https://discord.gg/m6YHGyJ) 6 | 7 | A flask extension serving as a framework to easily create virtual assistants using [Dialogflow](https://dialogflow.com/docs) which may be integrated 8 | with platforms such as [Actions on 9 | Google](https://developers.google.com/actions/develop/apiai/) (Google 10 | Assistant). 11 | 12 | Flask-Assistant allows you to focus on building the core business logic 13 | of conversational user interfaces while utilizing Dialogflow's Natural 14 | Language Processing to interact with users. 15 | 16 | **Now supports Dialogflow V2!** 17 | 18 | This project is heavily inspired and based on John Wheeler's 19 | [Flask-ask](https://github.com/johnwheeler/flask-ask) for the Alexa 20 | Skills Kit. 21 | 22 | Features 23 | -------- 24 | 25 | > - Mapping of user-triggered Intents to action functions 26 | > - Context support for crafting dialogue dependent on the user's requests 27 | > - Define prompts for missing parameters when they are not present in the users request or past active contexts 28 | > - A convenient syntax resembling Flask's decoratored routing 29 | > - Rich Responses for Google Assistant 30 | 31 | Hello World 32 | ----------- 33 | 34 | ```python 35 | from flask import Flask 36 | from flask_assistant import Assistant, ask 37 | 38 | app = Flask(__name__) 39 | assist = Assistant(app, project_id="GOOGLE_CLOUD_PROJECT_ID") 40 | 41 | @assist.action("Demo") 42 | def hello_world(): 43 | speech = "Microphone check 1, 2 what is this?" 44 | return ask(speech) 45 | 46 | if __name__ == "__main__": 47 | app.run(debug=True) 48 | ``` 49 | 50 | How-To 51 | ------ 52 | 53 | > 1. Create an Assistant object with a Flask app. 54 | > 2. Use action decorators to map intents to the 55 | > proper action function. 56 | > 3. Use action view functions to return ask or tell responses. 57 | 58 | Documentation 59 | ------------- 60 | 61 | - Check out the [Quick 62 | Start](http://flask-assistant.readthedocs.io/en/latest/quick_start.html) 63 | to jump right in 64 | - View the full 65 | [documentation](http://flask-assistant.readthedocs.io/en/latest/) 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###Python### 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython 71 | profile_default/ 72 | ipython_config.py 73 | 74 | # Environments 75 | .env 76 | .venv 77 | env/ 78 | venv/ 79 | ENV/ 80 | env.bak/ 81 | venv.bak/ 82 | 83 | 84 | # Spyder project settings 85 | .spyderproject 86 | .spyproject 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 92 | # install all needed dependencies. 93 | Pipfile.lock 94 | 95 | # pyenv 96 | .python-version 97 | 98 | 99 | # Pycharm 100 | .idea/ 101 | 102 | # vscode 103 | .vscode 104 | 105 | 106 | # SublimeText 107 | 108 | # cache files for sublime text 109 | *.tmlanguage.cache 110 | *.tmPreferences.cache 111 | *.stTheme.cache 112 | 113 | # workspace files are user-specific 114 | *.sublime-workspace 115 | 116 | # project files should be checked into the repository, unless a significant 117 | # proportion of contributors will probably not be using SublimeText 118 | *.sublime-project 119 | 120 | # sftp configuration file 121 | sftp-config.json 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | -------------------------------------------------------------------------------- /flask_assistant/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from typing import Dict, Any 3 | import os 4 | import sys 5 | import logging 6 | from google.auth import jwt 7 | from flask_assistant.core import Assistant 8 | from . import logger 9 | import requests 10 | 11 | logger.setLevel(logging.INFO) 12 | 13 | def import_with_3(module_name, path): 14 | import importlib.util 15 | 16 | spec = importlib.util.spec_from_file_location(module_name, path) 17 | module = importlib.util.module_from_spec(spec) 18 | spec.loader.exec_module(module) 19 | return module 20 | 21 | 22 | def import_with_2(module_name, path): 23 | import imp 24 | 25 | return imp.load_source(module_name, path) 26 | 27 | 28 | def get_assistant(filename): 29 | """Imports a module from filename as a string, returns the contained Assistant object""" 30 | 31 | agent_name = os.path.splitext(filename)[0] 32 | 33 | try: 34 | agent_module = import_with_3(agent_name, os.path.join(os.getcwd(), filename)) 35 | 36 | except ImportError: 37 | agent_module = import_with_2(agent_name, os.path.join(os.getcwd(), filename)) 38 | 39 | for name, obj in agent_module.__dict__.items(): 40 | if isinstance(obj, Assistant): 41 | return obj 42 | 43 | 44 | def decode_token(token, client_id): 45 | r = requests.get('https://accounts.google.com/.well-known/openid-configuration') 46 | if(r.status_code != 200): 47 | print("status_code != 200; status_code =", r.status_code) 48 | print(r) 49 | try: 50 | return {'status':'BAD','error':r.status_code, "output": r.text} 51 | except requests.exceptions.RequestException as e: 52 | return {'status':'BAD','error':r.status_code} 53 | 54 | if "jwks_uri" not in r.json(): 55 | error_message = "Missing jwks_uri in 'https://accounts.google.com/.well-known/openid-configuration'" 56 | print(error_message) 57 | return {'status':'BAD','error':error_message} 58 | 59 | googleapis_certs = r.json()["jwks_uri"].replace("/v3/", "/v1/") 60 | r = requests.get(googleapis_certs) 61 | if(r.status_code != 200): 62 | print("status_code != 200; status_code =", r.status_code) 63 | print(r) 64 | try: 65 | return {'status':'BAD','error':r.status_code, "output": r.text} 66 | except requests.exceptions.RequestException as e: 67 | return {'status':'BAD','error':r.status_code} 68 | else: 69 | public_keys = r.json() 70 | decoded = jwt.decode(token, certs=public_keys, verify=True, audience=client_id) 71 | return {'status':'OK','output': decoded} 72 | 73 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def build_payload( 5 | intent, params={}, contexts=[], action="test_action", query="test query" 6 | ): 7 | 8 | return json.dumps( 9 | { 10 | "originalDetectIntentRequest": { 11 | "source": "google", 12 | "version": "2", 13 | "data": { 14 | "isInSandbox": False, 15 | "surface": { 16 | "capabilities": [ 17 | {"name": "actions.capability.AUDIO_OUTPUT"}, 18 | {"name": "actions.capability.SCREEN_OUTPUT"}, 19 | {"name": "actions.capability.MEDIA_RESPONSE_AUDIO"}, 20 | {"name": "actions.capability.WEB_BROWSER"}, 21 | ] 22 | }, 23 | "inputs": [{}], 24 | "user": { 25 | "lastSeen": "2018-04-23T15:10:43Z", 26 | "locale": "en-US", 27 | "userId": "abcdefg", 28 | "accessToken": "foobar", 29 | }, 30 | "conversation": { 31 | "conversationId": "123456789", 32 | "type": "ACTIVE", 33 | "conversationToken": "[]", 34 | }, 35 | "availableSurfaces": [ 36 | { 37 | "capabilities": [ 38 | {"name": "actions.capability.AUDIO_OUTPUT"}, 39 | {"name": "actions.capability.SCREEN_OUTPUT"}, 40 | ] 41 | } 42 | ], 43 | }, 44 | }, 45 | "responseId": "8ea2d357-10c0-40d1-b1dc-e109cd714f67", 46 | "queryResult": { 47 | "action": action, 48 | "allRequiredParamsCollected": True, 49 | "outputContexts": contexts, 50 | "languageCode": "en", 51 | "fulfillment": {"messages": [], "text": ""}, 52 | "intent": { 53 | "name": "some-intent-id", 54 | "displayName": intent, 55 | # "webhookForSlotFillingUsed": "false", 56 | "webhookState": True, 57 | }, 58 | "parameters": params, 59 | "resolvedQuery": query, 60 | "intentDetectionConfidence": 1.0, 61 | }, 62 | "session": "projects/test-project-id/agent/sessions/88d13aa8-2999-4f71-b233-39cbf3a824a0", 63 | } 64 | ) 65 | 66 | 67 | def get_query_response(client, payload): 68 | resp = client.post("/", data=payload) 69 | assert resp.status_code == 200 70 | return json.loads(resp.data.decode("utf-8")) 71 | -------------------------------------------------------------------------------- /tests/test_context_manager.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask_assistant.manager import Context, ContextManager 3 | 4 | 5 | @pytest.fixture(scope="function") 6 | def manager(simple_assist): 7 | 8 | # give dummy session_id b/c 9 | # these tests won't recieve requests 10 | # simple_assist.session_id = "test-session-id" 11 | m = ContextManager(assist=simple_assist) 12 | assert len(m._cache) == 0 13 | assert len(m.active) == 0 14 | return m 15 | 16 | 17 | def test_get_non_existant_context(manager): 18 | result = manager.get("doesnt-exist") 19 | assert result is None 20 | 21 | 22 | def test_add_context(manager): 23 | c = manager.add("sample") 24 | assert isinstance(c, Context) 25 | assert len(manager._cache) == 1 26 | assert len(manager.active) == 1 27 | assert Context("sample") == manager._cache["sample"] 28 | assert c in manager.active 29 | assert Context("sample") in manager.active 30 | 31 | 32 | def test_add_and_get_context(manager): 33 | assert manager.get("sample") is None 34 | added = manager.add("sample") 35 | retrieved = manager.get("sample") 36 | assert added is retrieved 37 | assert retrieved.lifespan == 5 38 | 39 | 40 | def test_add_context_with_params(manager): 41 | manager.add("sample", parameters={"param1": 1, "param2": "two"}) 42 | c = manager.get("sample") 43 | assert c.get("param1") == 1 44 | assert c.get("param2") == "two" 45 | 46 | 47 | def test_get_param_from_manager(manager): 48 | manager.add("sample", parameters={"param1": 1, "param2": "two"}) 49 | assert manager.get_param("sample", "param1") == 1 50 | assert manager.get_param("sample", "param2") == "two" 51 | 52 | 53 | def test_set_param_from_manager(manager): 54 | c = manager.add("sample") 55 | assert c.get("param1") is None 56 | 57 | manager.set("sample", "param1", 1) 58 | assert manager.get("sample").get("param1") == 1 # thru manager 59 | assert c.get("param1") == 1 # check original context object 60 | 61 | 62 | def test_active_and_expired(manager): 63 | c1 = manager.add("sample1", lifespan=1) 64 | c2 = manager.add("sample2", lifespan=3) 65 | c3 = manager.add("sample3", lifespan=0) 66 | 67 | assert len(manager._cache) == 3 68 | assert len(manager.active) == 2 69 | assert len(manager.expired) == 1 70 | 71 | assert c1 in manager.active 72 | assert c2 in manager.active 73 | assert c3 in manager.expired 74 | 75 | 76 | def test_add_contexts_and_clear_all(manager): 77 | c1 = manager.add("sample1", lifespan=1) 78 | c2 = manager.add("sample2", lifespan=2) 79 | c3 = manager.add("sample3", lifespan=3) 80 | c3 = manager.add("sample4", lifespan=0) 81 | 82 | assert len(manager._cache) == 4 83 | assert len(manager.active) == 3 84 | assert len(manager.expired) == 1 85 | 86 | manager.clear_all() 87 | 88 | assert len(manager._cache) == 4 89 | assert len(manager.active) == 0 90 | assert len(manager.expired) == 4 91 | -------------------------------------------------------------------------------- /tests/test_assistant.py: -------------------------------------------------------------------------------- 1 | from flask_assistant.manager import Context 2 | from tests.helpers import build_payload, get_query_response 3 | 4 | 5 | def test_intents_with_different_formatting(simple_client, intent_payload): 6 | 7 | resp = get_query_response(simple_client, intent_payload) 8 | assert "Message" in resp["fulfillmentText"] 9 | 10 | resp = simple_client.post("/", data=intent_payload) 11 | assert resp.status_code == 200 12 | 13 | assert b"Message" in resp.data 14 | 15 | 16 | def test_add_context_to_response(context_assist): 17 | client = context_assist.app.test_client() 18 | payload = build_payload("AddContext") 19 | resp = get_query_response(client, payload) 20 | 21 | # full_name = f"projects/{context_assist._project_id}/agent/sessions/{context_assist.session_id}/contexts/SampleContext" 22 | # context_item = {"lifespanCount": 5, "name": full_name, "parameters": {}} 23 | 24 | # TODO access context_manager from assist, check for full context name 25 | assert "SampleContext" in resp["outputContexts"][0]["name"] 26 | # assert context_item in resp["outputContexts"] 27 | 28 | 29 | def test_add_context_to_manager(context_assist): 30 | # with statement allows context locals to be accessed 31 | # remains within the actual request to the flask app 32 | with context_assist.app.test_client() as client: 33 | payload = build_payload("AddContext") 34 | resp = get_query_response(client, payload) 35 | context_obj = Context("SampleContext") 36 | assert context_obj in context_assist.context_manager.active 37 | 38 | 39 | # def test_need_context_to_match_action(context_assist): 40 | # with context_assist.app.test_client() as client: 41 | # payload = build_payload('ContextRequired') 42 | # resp = get_query_response(client, payload) 43 | # assert 'Matched because SampleContext was active' not in resp['speech'] 44 | 45 | 46 | def test_docs_context(docs_assist): 47 | 48 | # adds 'vegetarian' context 49 | with docs_assist.app.test_client() as client: 50 | payload = build_payload("give-diet", params={"diet": "vegetarian"}) 51 | resp = get_query_response(client, payload) 52 | context_obj = Context("vegetarian") 53 | assert context_obj in docs_assist.context_manager.active 54 | 55 | next_payload = build_payload("get-food", contexts=resp["outputContexts"]) 56 | next_resp = get_query_response(client, next_payload) 57 | assert "farmers market" in next_resp["fulfillmentText"] 58 | 59 | # adds 'carnivore' context 60 | with docs_assist.app.test_client() as client: 61 | payload = build_payload("give-diet", params={"diet": "carnivore"}) 62 | resp = get_query_response(client, payload) 63 | context_obj = Context("carnivore") 64 | assert context_obj in docs_assist.context_manager.active 65 | 66 | next_payload = build_payload("get-food", contexts=resp["outputContexts"]) 67 | next_resp = get_query_response(client, next_payload) 68 | assert "farmers market" not in next_resp["fulfillmentText"] 69 | assert "BBQ" in next_resp["fulfillmentText"] 70 | -------------------------------------------------------------------------------- /samples/reservation/agent.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import Flask 3 | from flask_assistant import Assistant, ask, tell, context_manager 4 | 5 | 6 | app = Flask(__name__) 7 | assist = Assistant(app) 8 | logging.getLogger('flask_assistant').setLevel(logging.DEBUG) 9 | 10 | 11 | @assist.action('welcome') 12 | def welcome(): 13 | speech = """Welcome to Booking McBookface Adventures. 14 | do you want to travel via plane, train, or automobile? 15 | """ 16 | return ask(speech) 17 | 18 | 19 | @assist.action('declare-transport') 20 | def transport_method(transport): 21 | # set context to hold all trip info 22 | trip = context_manager.add('trip').set('transport', transport) 23 | 24 | # parameters can also be stored in context as dictionary keys 25 | trip['transport'] = transport 26 | 27 | speech = " Ok, you chose {} right?".format(transport) 28 | return ask(speech) 29 | 30 | 31 | @assist.context('trip') 32 | @assist.action('confirm') 33 | def confirm_transport(answer): 34 | if 'n' in answer: 35 | return ask('I dont think I understood. What transportation do you want to take?') 36 | else: 37 | return ask('Ok, is this going to be a one-way trip or round ticket?') 38 | 39 | 40 | # view_function parameters, if not provided with the user's query, 41 | # will be accessed from reviously declared contexts 42 | @assist.context('trip') 43 | @assist.action('delcare-ticket-type') 44 | def oneway_or_round(ticket_type, transport): 45 | context_manager.add(ticket_type) # set context for one way/round trip dialogues 46 | context_manager.add('departure') 47 | speech = 'Cool, what city do you want to leave from for your {} {} trip?'.format(ticket_type, transport) 48 | return ask(speech) 49 | 50 | 51 | @assist.context('departure') 52 | @assist.action('give-city') 53 | def departure_location(city): 54 | context_manager.set('departure', 'city', city) 55 | return ask('What day would you like to leave?') 56 | 57 | 58 | @assist.context('departure') 59 | @assist.action('give-day') 60 | def departure_date(day): 61 | context_manager.set('departure', 'day', day) 62 | context_manager.add('arrival') 63 | speech = 'Ok would you like to confrim your booking details?' 64 | return ask(speech) 65 | 66 | @assist.context('arrival') 67 | @assist.action('give-city') 68 | def get_destination(city): 69 | context_manager.set('arrival', 'city', city) 70 | speech = 'Would you like to book a room at the McHotelFace hotel in {}?'.format(city) 71 | return ask(speech) 72 | 73 | @assist.context('arrival') 74 | @assist.action('book-hotel') 75 | def book_hotel(answer): 76 | context_manager.set('arrival', 'book_hotel', answer) 77 | if 'y' in answer: 78 | hotel_speech = 'Ok, your room will be ready for you.' 79 | else: 80 | hotel_speech = 'No hotel needed, got it.' 81 | 82 | 83 | 84 | 85 | 86 | @assist.context('departure', 'one-way') 87 | @assist.action('confirm') 88 | def confirm_one_way_details(answer, transport, ticket_type, city, date): 89 | if 'y' in answer: 90 | speech = "I have you set for a {} {} trip. You will be leaving from {} on {} and you will never return!" 91 | return tell(speech) 92 | 93 | # @assist.context('return', 'round') 94 | # @assist.action('give-city') 95 | # def return_location(city): 96 | 97 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Flask-Assistant documentation master file, created by 2 | sphinx-quickstart on Wed Jan 18 17:49:02 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | *************************** 8 | Welcome to Flask-Assistant! 9 | *************************** 10 | 11 | .. _`Google Actions`: https://developers.google.com/actions/dialogflow/ 12 | .. _`fullfillment`: https://dialogflow.com/docs/fulfillment 13 | .. _Dialogflow: https://dialogflow.com/docs 14 | 15 | A flask extension serving as an `Dialogflow`_ SDK to provide an easy way to create virtual assistants which may be integrated with platforms such as `Google Actions`_ (Google Home). 16 | 17 | Flask-Assistant allows you to focus on building the core business logic of conversational user interfaces while utilizing Dialogflow's Natural Language Processing to interact with users. 18 | 19 | 20 | .. This framework provides the ability to: 21 | .. ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 22 | .. - Quickly create fullfillment_ webhooks 23 | .. - Define and register Dialogflow schema 24 | .. - Design a conversational flow to build contextual dialogues with Dialogflow's concept of contexts 25 | 26 | 27 | 28 | 29 | Features 30 | ======== 31 | 32 | - Maping of user-triggered Intents to action view functions 33 | - Context support for crafting dialogue dependent on the user's requests 34 | - Define prompts for missing parameters when they are not present in the users request or past active contexs 35 | - A convenient syntax resembling Flask's decoratored routing 36 | - Internal Dialogflow schema generation and registration 37 | 38 | 39 | 40 | A Minimal Assistant 41 | =================== 42 | 43 | .. code-block:: python 44 | 45 | from flask import Flask 46 | from flask_assistant import Assistant, tell 47 | 48 | # to see the full request and response objects 49 | # set logging level to DEBUG 50 | import logging 51 | logging.getLogger('flask_assistant').setLevel(logging.DEBUG) 52 | 53 | app = Flask(__name__) 54 | assist = Assistant(app, project_id='GOOGLE_CLOUD_PROJECT_ID') 55 | 56 | @assist.action('Demo') 57 | def hello_world(): 58 | speech = 'Microphone check 1, 2 what is this?' 59 | return tell(speech) 60 | 61 | if __name__ == '__main__': 62 | app.run(debug=True) 63 | 64 | As you can see, structure of an Assistant app resembles the structure of a regular Flask app. 65 | 66 | Explanation 67 | ----------- 68 | 69 | 1. Initialized an :class:`Assistant ` object with a Flask app and the route to your webhook URL. 70 | 2. Used the :meth:`action ` decorator to map the `greetings` intent to the proper action function. 71 | - The action decorator accepts the name of an intent as a parameter 72 | - The decorated function serves as the action view function, called when an Dialogflow request sent on behalf of the `send-message` intent is received 73 | 3. The action function returns an :class:`ask ` response containing text/speech which prompts the user for the next intent. 74 | 75 | 76 | Check out the :doc:`quick_start` to see how to quickly build an assistant 77 | 78 | 79 | .. include:: contents.rst.inc 80 | 81 | .. Indices 82 | .. ================== 83 | 84 | .. * :ref:`genindex` 85 | .. * :ref:`modindex` 86 | .. * :ref:`search` 87 | -------------------------------------------------------------------------------- /api_ai/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import sys 3 | import logging 4 | from flask_assistant.utils import get_assistant 5 | from .schema_handlers import IntentGenerator, EntityGenerator, TemplateCreator 6 | from .api import ApiAi 7 | from . import logger 8 | from multiprocessing import Process 9 | 10 | logger.setLevel(logging.INFO) 11 | 12 | api = ApiAi() 13 | 14 | raise DeprecationWarning( 15 | "Schema generation and management is not yet available for Dialogflow V2, please define intents and entities in the Dialogflow console" 16 | ) 17 | 18 | 19 | def file_from_args(): 20 | try: 21 | return sys.argv[1] 22 | except IndexError: 23 | raise IndexError("Please provide the file containing the Assistant object") 24 | 25 | 26 | def gen_templates(): 27 | filename = file_from_args() 28 | assist = get_assistant(filename) 29 | templates = TemplateCreator(assist) 30 | templates.generate() 31 | 32 | 33 | def intents(): 34 | logger.info("Getting Registered Intents...") 35 | filename = file_from_args() 36 | assist = get_assistant(filename) 37 | intents = assist.api.agent_intents 38 | for i in intents: 39 | logger.info(i.name) 40 | return intents 41 | 42 | 43 | def entities(): 44 | logger.info("Getting Registered Entities...") 45 | filename = file_from_args() 46 | assist = get_assistant(filename) 47 | ents = assist.api.agent_entities 48 | for i in ents: 49 | logger.info(i.name) 50 | return ents 51 | 52 | 53 | def schema(): 54 | filename = file_from_args() 55 | assist = get_assistant(filename) 56 | intents = IntentGenerator(assist) 57 | entities = EntityGenerator(assist) 58 | templates = TemplateCreator(assist) 59 | 60 | templates.generate() 61 | intents.generate() 62 | entities.generate() 63 | 64 | 65 | def check(): 66 | filename = file_from_args() 67 | assist = get_assistant(filename) 68 | # reg_total = len(assist.api.agent_intents) 69 | # map_total = len(assist._intent_action_funcs) 70 | reg_names = [i.name for i in assist.api.agent_intents] 71 | map_names = [i for i in assist._intent_action_funcs.keys()] 72 | extra_reg = set(reg_names) - set(map_names) 73 | extra_map = set(map_names) - set(reg_names) 74 | 75 | if extra_reg != set(): 76 | print( 77 | "\nThe following Intents are registered but not mapped to an action function:" 78 | ) 79 | print(extra_reg) 80 | print() 81 | else: 82 | print("\n All registered intents are mapped\n") 83 | 84 | if extra_map != set(): 85 | print( 86 | "\nThe Following Intents are mapped to an action fucntion, but not registered: " 87 | ) 88 | print(extra_map) 89 | print() 90 | else: 91 | print("\n All mapped intents are regitsered\n") 92 | 93 | print("Registered Entities:") 94 | print([i.name for i in assist.api.agent_entities]) 95 | 96 | 97 | def query(): 98 | filename = file_from_args() 99 | assist = get_assistant(filename) 100 | p = Process(target=assist.app.run) 101 | p.start() 102 | 103 | while True: 104 | q = input("Enter query...\n") 105 | resp = assist.api.post_query(q).json() 106 | try: 107 | print("Matched: {}".format(resp["result"]["metadata"]["intentName"])) 108 | print("Params: {}".format(resp["result"]["parameters"])) 109 | print(resp["result"]["fulfillment"]["speech"]) 110 | 111 | except KeyError: 112 | logger.error("Error:") 113 | logger.error(resp["status"]) 114 | -------------------------------------------------------------------------------- /flask_assistant/manager.py: -------------------------------------------------------------------------------- 1 | from flask_assistant import logger 2 | 3 | 4 | def parse_context_name(context_obj): 5 | """Parses context name from Dialogflow's contextsession prefixed context path""" 6 | return context_obj["name"].split("/contexts/")[1] 7 | 8 | 9 | class Context(dict): 10 | """This is a docstring for _Context""" 11 | 12 | def __init__(self, name, parameters={}, lifespan=5): 13 | 14 | self.name = name 15 | self.parameters = parameters 16 | self.lifespan = lifespan 17 | self._full_name = None 18 | 19 | # def __getattr__(self, param): 20 | # if param in ['name', 'parameters', 'lifespan']: 21 | # return getattr(self, param) 22 | # return self.parameters[param] 23 | 24 | def set(self, param_name, value): 25 | self.parameters[param_name] = value 26 | 27 | def get(self, param): 28 | return self.parameters.get(param) 29 | 30 | def sync(self, context_json): 31 | self.__dict__.update(context_json) 32 | 33 | def __repr__(self): 34 | return self.name 35 | 36 | @property 37 | def serialize(self): 38 | return { 39 | "name": self._full_name, 40 | "lifespanCount": self.lifespan, 41 | "parameters": self.parameters, 42 | } 43 | 44 | 45 | class ContextManager: 46 | def __init__(self, assist): 47 | self._assist = assist 48 | self._cache = {} 49 | 50 | @property 51 | def _project_id(self): 52 | return self._assist.project_id 53 | 54 | @property 55 | def _session_id(self): 56 | return self._assist.session_id 57 | 58 | def build_full_name(self, short_name): 59 | return "projects/{}/agent/sessions/{}/contexts/{}".format( 60 | self._project_id, self._session_id, short_name 61 | ) 62 | 63 | def add(self, *args, **kwargs): 64 | context = Context(*args, **kwargs) 65 | context._full_name = self.build_full_name(context.name) 66 | self._cache[context.name] = context 67 | return context 68 | 69 | def get(self, context_name, default=None): 70 | return self._cache.get(context_name, default) 71 | 72 | def set(self, context_name, param, val): 73 | context = self.get(context_name) 74 | context.set(param, val) 75 | self._cache[context.name] = context 76 | return context 77 | 78 | def get_param(self, context_name, param): 79 | return self._cache[context_name].parameters[param] 80 | 81 | def update(self, contexts_json): 82 | for obj in contexts_json: 83 | short_name = parse_context_name(obj) 84 | context = Context(short_name) 85 | context._full_name = obj["name"] 86 | context.lifespan = obj.get("lifespanCount", 0) 87 | context.parameters = obj.get("parameters", {}) 88 | self._cache[context.name] = context 89 | 90 | def clear_all(self): 91 | logger.info("Clearing all contexts") 92 | new_cache = {} 93 | for name, context in self._cache.items(): 94 | context.lifespan = 0 95 | new_cache[name] = context 96 | 97 | self._cache = new_cache 98 | 99 | @property 100 | def status(self): 101 | return {"Active contexts": self.active, "Expired contexts": self.expired} 102 | 103 | @property 104 | def active(self): 105 | return [self._cache[c] for c in self._cache if self._cache[c].lifespan > 0] 106 | 107 | @property 108 | def expired(self): 109 | return [self._cache[c] for c in self._cache if self._cache[c].lifespan == 0] 110 | -------------------------------------------------------------------------------- /flask_assistant/hass.py: -------------------------------------------------------------------------------- 1 | import homeassistant.remote as remote 2 | 3 | class HassRemote(object): 4 | """Wrapper around homeassistant.remote to make requests to HA's REST api""" 5 | 6 | def __init__(self, password, host='127.0.0.1', port=8123, use_ssl=False): 7 | self.password = password 8 | self.host = host 9 | self.port = port 10 | self.use_ssl = use_ssl 11 | self.api = None 12 | 13 | self.connect() 14 | 15 | def connect(self): 16 | self.api = remote.API(self.host, api_password=self.password, port=self.port, use_ssl=self.use_ssl) 17 | print('Connecting to Home Assistant instance...') 18 | print(remote.validate_api(self.api)) 19 | 20 | 21 | @property 22 | def _config(self): 23 | return remote.get_config(self.api) 24 | 25 | @property 26 | def _event_listeners(self): 27 | return remote.get_event_listeners 28 | 29 | @property 30 | def _services(self): 31 | return remote.get_services(self.api) 32 | 33 | @property 34 | def _states(self): 35 | return remote.get_states(self.api) 36 | 37 | @property 38 | def domains(self): 39 | return [s['domain'] for s in self._services] 40 | 41 | @property 42 | def services(self): 43 | return [s['services'] for s in self._services] 44 | 45 | def get_state(self, entity_id): 46 | return remote.get_state(self.api, entity_id) 47 | 48 | def set_state(self, entity_id, new_state, **kwargs): 49 | "Updates or creates the current state of an entity." 50 | return remote.set_state(self.api, new_state, **kwargs) 51 | 52 | def is_state(self, entity_id, state): 53 | """Checks if the entity has the given state""" 54 | return remote.is_state(self.api, entity_id, state) 55 | 56 | def call_service(self, domain, service, service_data={}, timeout=5): 57 | return remote.call_service(self.api, domain, service, service_data=service_data, timeout=timeout) 58 | 59 | 60 | # reports 61 | @property 62 | def light_states(self): 63 | return [i for i in self._states if i.domain == 'light'] 64 | 65 | @property 66 | def sensors(self): 67 | return [i for i in self._states if i.domain == 'sensor'] 68 | 69 | # Shortcut service calls 70 | def switch(self, switch_name, service='toggle'): 71 | data = {'entity_id': 'script.{}'.format(switch_name)} 72 | return remote.call_service(self.api, 'switch', service, service_data=data) 73 | 74 | def turn_off_light(self, light_name): 75 | data = {'entity_id': 'light.{}'.format(light_name)} 76 | return remote.call_service(self.api, 'light', 'turn_off', service_data=data) 77 | 78 | def turn_on_light(self, light_name, brightness=255): 79 | data = {'entity_id': 'light.{}'.format(light_name), 'brightness': brightness} 80 | return remote.call_service (self.api, 'light', 'turn_on', service_data=data) 81 | 82 | # def turn_on_group(self, group_name, **kwargs): 83 | # data = {'entity_id': 'group.{}'.format(group_name)} 84 | # data.update(kwargs) 85 | # return remote.call_service(self.api, 'homeassistant', 'turn_on', service_data=data) 86 | 87 | # def turn_off_group(self, group_name): 88 | # data = {'entity_id': 'group.{}'.format(group_name)} 89 | # return remote.call_service(self.api, 'homeassistant', 'turn_off', service_data=data) 90 | 91 | def start_script(self, script_name): 92 | # data = {'entity_id': 'script.{}'.format(script_name)} 93 | return remote.call_service(self.api, 'script', 'script_name') 94 | 95 | def command(self, shell_command): 96 | return remote.call_service(self.api, 'shell_command', shell_command, timeout=10) 97 | 98 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import pytest 4 | from flask import Flask 5 | import flask_assistant 6 | from flask_assistant import Assistant, ask, tell, context_manager as manager 7 | from flask_assistant.utils import get_assistant 8 | from flask_assistant.manager import ContextManager 9 | from tests.helpers import build_payload 10 | 11 | 12 | PROJECT_ROOT = os.path.abspath(os.path.join(flask_assistant.__file__, "../..")) 13 | 14 | 15 | @pytest.fixture(autouse=True) 16 | def no_requests(monkeypatch): 17 | monkeypatch.delattr("requests.sessions.Session.request") 18 | 19 | 20 | ## Samples ## 21 | @pytest.fixture(scope="session") 22 | def hello_world_assist(): 23 | filename = os.path.join(PROJECT_ROOT, "samples", "hello_world", "webhook.py") 24 | return get_assistant(filename) 25 | 26 | 27 | ## Basic Assistant to test Intent matching ## 28 | @pytest.fixture(scope="session") 29 | def simple_assist(): 30 | app = Flask(__name__) 31 | assist = Assistant(app, project_id="test-project-id") 32 | 33 | @assist.action("TestIntent") 34 | def test_1(): 35 | speech = "Message1" 36 | return ask(speech) 37 | 38 | @assist.action("test_intent_2") 39 | def test_2(): 40 | speech = "Message2" 41 | return ask(speech) 42 | 43 | @assist.action("test intent 3") 44 | def test_3(): 45 | speech = "Message3" 46 | return ask(speech) 47 | 48 | @assist.action("TestIntent") 49 | def test_action(): 50 | speech = "Message1" 51 | return ask(speech) 52 | 53 | return assist 54 | 55 | 56 | @pytest.fixture(scope="session") 57 | def simple_client(simple_assist): 58 | return simple_assist.app.test_client() 59 | 60 | 61 | @pytest.fixture(params=["TestIntent", "test_intent_2", "test intent 3"]) 62 | def intent_payload(request): 63 | return build_payload(intent=request.param) 64 | 65 | 66 | ## Assistant to test contexts ## 67 | @pytest.fixture(scope="session") 68 | def context_assist(): 69 | 70 | app = Flask(__name__) 71 | assist = Assistant(app, project_id="test-project-id") 72 | 73 | @assist.action("AddContext") 74 | def add_context(): 75 | speech = "Adding context to context_out" 76 | manager.add("SampleContext") 77 | return ask(speech) 78 | 79 | @assist.context("SampleContext") 80 | @assist.action("ContextRequired") 81 | def context_dependent_action(): 82 | speech = "Matched because SampleContext was active" 83 | return ask(speech) 84 | 85 | @assist.action("ContextRequired") 86 | def action_func(): 87 | speech = "Message" 88 | return ask(speech) 89 | 90 | @assist.action("ContextNotRequired") 91 | def context_independent_actions(): 92 | speech = "No context required" 93 | return ask(speech) 94 | 95 | return assist 96 | 97 | 98 | ## Assistant to test docs examples ## 99 | @pytest.fixture(scope="session") 100 | def docs_assist(): 101 | 102 | app = Flask(__name__) 103 | assist = Assistant(app) 104 | 105 | @assist.action("give-diet") 106 | def set_user_diet(diet): 107 | speech = "Are you trying to make food or get food?" 108 | manager.add(diet) 109 | return ask(speech) 110 | 111 | @assist.context("vegetarian") 112 | @assist.action("get-food") 113 | def suggest_food(): 114 | return tell("There's a farmers market tonight.") 115 | 116 | @assist.context("carnivore") 117 | @assist.action("get-food") 118 | def suggest_food(): 119 | return tell("Bob's BBQ has some great tri tip") 120 | 121 | @assist.context("broke") 122 | @assist.action("get-food") 123 | def suggest_food(): 124 | return tell("Del Taco is open late") 125 | 126 | return assist 127 | -------------------------------------------------------------------------------- /docs/source/parameters.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ******************** 5 | Accepting Parameters 6 | ******************** 7 | Action functions can accept parameters, which will be parsed from the API.AI request as `entities `_ 8 | 9 | For a parameter value to be parsed by API.AI's NLP, it needs to relate to a defined entity. 10 | In other words, the name of the parameter must be the name of the entity it relates to. 11 | 12 | Entities are defined: 13 | - using YAML templates and the :doc:`schema ` command 14 | - within the API.AI console 15 | - as existing API.AI `System Entities `_ 16 | 17 | Each entity is composed of entries, which represent a mapping between a reference value and a group of synonyms. Entities will be the specific value passed to the action function. 18 | 19 | 20 | Parameters for Custom Entities 21 | =============================== 22 | 23 | Given an entity ``color`` defined with the following template: 24 | 25 | .. code-block:: yaml 26 | 27 | color: 28 | - blue 29 | - red 30 | - green 31 | 32 | An action function may accept a parameter referring to an `entry` (blue, red, green) of the ``color`` `entity`: 33 | 34 | .. code-block:: python 35 | 36 | @assist.action('give-color') 37 | def echo_color(color): 38 | speech = "Your favorite color is {}".format(color) 39 | return tell(speech) 40 | 41 | 42 | Mapping Parameters to API.AI System Entities 43 | ============================================== 44 | 45 | Every parameter passed to an action function needs to correspond to a defined entity. 46 | These ``entities`` require defined ``entries`` in order to be parsed using NLP. 47 | 48 | With the `color` example above, we defined three entries (blue, red, and green). To allow our assistant to accurately parse and handle all the possible colors a user might say, we would need to provide a great number of entries. 49 | 50 | Instead of defining many entries for common entity concepts (color, names, addresses, etc), you can utilize API.AI's `System Entities `_. 51 | 52 | To use system entities, simply provide a mapping of the parameter name to corresponding system entity: 53 | 54 | .. code-block:: python 55 | 56 | @assist.action('give-color', mapping={'color': 'sys.color'}) 57 | def echo_color(color): 58 | speech = "Your favorite color is {}".format(color) 59 | return tell(speech) 60 | 61 | And in the user_says template: 62 | 63 | .. code-block:: yaml 64 | 65 | give-color: 66 | 67 | UserSays: 68 | - My color is blue 69 | - I like red 70 | 71 | Annotations: 72 | - blue: sys.color 73 | 74 | No entity-template is needed for the `sys.color` entity, as it is already defined. API.AI will automatically recognize any color spoken by the user to be parsed as its ``sys.color`` entity, and flask-assistant will match the correct parameter value to the `color` parameter. 75 | 76 | 77 | 78 | 79 | Prompting for Parameters 80 | ======================== 81 | 82 | When an action function accepts a parameter, it is required unless a default is provided. 83 | 84 | If the parameter is not provided by the user, or was not defined in a previous context, the action function will not be called. 85 | 86 | This is where :meth:`prompt_for` comes in handy. 87 | 88 | The :meth:`prompt_for ` decorator is passed a parameter name and intent name, and is called if the intent's action function's parameters have not been supplied. 89 | 90 | .. code-block:: python 91 | 92 | @assist.prompt_for('color', intent_name='give-color') 93 | def prompt_color(color): 94 | speech = "Sorry I didn't catch that. What color did you say?" 95 | return ask(speech) -------------------------------------------------------------------------------- /docs/source/_themes/flask_theme_support.py: -------------------------------------------------------------------------------- 1 | # flasky extensions. flasky pygments style based on tango style 2 | from pygments.style import Style 3 | from pygments.token import Keyword, Name, Comment, String, Error, \ 4 | Number, Operator, Generic, Whitespace, Punctuation, Other, Literal 5 | 6 | 7 | class FlaskyStyle(Style): 8 | background_color = "#f8f8f8" 9 | default_style = "" 10 | 11 | styles = { 12 | # No corresponding class for the following: 13 | #Text: "", # class: '' 14 | Whitespace: "underline #f8f8f8", # class: 'w' 15 | Error: "#a40000 border:#ef2929", # class: 'err' 16 | Other: "#000000", # class 'x' 17 | 18 | Comment: "italic #8f5902", # class: 'c' 19 | Comment.Preproc: "noitalic", # class: 'cp' 20 | 21 | Keyword: "bold #004461", # class: 'k' 22 | Keyword.Constant: "bold #004461", # class: 'kc' 23 | Keyword.Declaration: "bold #004461", # class: 'kd' 24 | Keyword.Namespace: "bold #004461", # class: 'kn' 25 | Keyword.Pseudo: "bold #004461", # class: 'kp' 26 | Keyword.Reserved: "bold #004461", # class: 'kr' 27 | Keyword.Type: "bold #004461", # class: 'kt' 28 | 29 | Operator: "#582800", # class: 'o' 30 | Operator.Word: "bold #004461", # class: 'ow' - like keywords 31 | 32 | Punctuation: "bold #000000", # class: 'p' 33 | 34 | # because special names such as Name.Class, Name.Function, etc. 35 | # are not recognized as such later in the parsing, we choose them 36 | # to look the same as ordinary variables. 37 | Name: "#000000", # class: 'n' 38 | Name.Attribute: "#c4a000", # class: 'na' - to be revised 39 | Name.Builtin: "#004461", # class: 'nb' 40 | Name.Builtin.Pseudo: "#3465a4", # class: 'bp' 41 | Name.Class: "#000000", # class: 'nc' - to be revised 42 | Name.Constant: "#000000", # class: 'no' - to be revised 43 | Name.Decorator: "#888", # class: 'nd' - to be revised 44 | Name.Entity: "#ce5c00", # class: 'ni' 45 | Name.Exception: "bold #cc0000", # class: 'ne' 46 | Name.Function: "#000000", # class: 'nf' 47 | Name.Property: "#000000", # class: 'py' 48 | Name.Label: "#f57900", # class: 'nl' 49 | Name.Namespace: "#000000", # class: 'nn' - to be revised 50 | Name.Other: "#000000", # class: 'nx' 51 | Name.Tag: "bold #004461", # class: 'nt' - like a keyword 52 | Name.Variable: "#000000", # class: 'nv' - to be revised 53 | Name.Variable.Class: "#000000", # class: 'vc' - to be revised 54 | Name.Variable.Global: "#000000", # class: 'vg' - to be revised 55 | Name.Variable.Instance: "#000000", # class: 'vi' - to be revised 56 | 57 | Number: "#990000", # class: 'm' 58 | 59 | Literal: "#000000", # class: 'l' 60 | Literal.Date: "#000000", # class: 'ld' 61 | 62 | String: "#4e9a06", # class: 's' 63 | String.Backtick: "#4e9a06", # class: 'sb' 64 | String.Char: "#4e9a06", # class: 'sc' 65 | String.Doc: "italic #8f5902", # class: 'sd' - like a comment 66 | String.Double: "#4e9a06", # class: 's2' 67 | String.Escape: "#4e9a06", # class: 'se' 68 | String.Heredoc: "#4e9a06", # class: 'sh' 69 | String.Interpol: "#4e9a06", # class: 'si' 70 | String.Other: "#4e9a06", # class: 'sx' 71 | String.Regex: "#4e9a06", # class: 'sr' 72 | String.Single: "#4e9a06", # class: 's1' 73 | String.Symbol: "#4e9a06", # class: 'ss' 74 | 75 | Generic: "#000000", # class: 'g' 76 | Generic.Deleted: "#a40000", # class: 'gd' 77 | Generic.Emph: "italic #000000", # class: 'ge' 78 | Generic.Error: "#ef2929", # class: 'gr' 79 | Generic.Heading: "bold #000080", # class: 'gh' 80 | Generic.Inserted: "#00A000", # class: 'gi' 81 | Generic.Output: "#888", # class: 'go' 82 | Generic.Prompt: "#745334", # class: 'gp' 83 | Generic.Strong: "bold #000000", # class: 'gs' 84 | Generic.Subheading: "bold #800080", # class: 'gu' 85 | Generic.Traceback: "bold #a40000", # class: 'gt' 86 | } 87 | -------------------------------------------------------------------------------- /samples/pizza_contexts/agent.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import Flask 3 | from flask_assistant import Assistant, ask, tell, context_manager 4 | 5 | 6 | app = Flask(__name__) 7 | assist = Assistant(app) 8 | logging.getLogger('flask_assistant').setLevel(logging.DEBUG) 9 | 10 | 11 | @assist.action('greetings') 12 | def greetings(): 13 | speech = """We've got some bumpin pies up in here!. 14 | Would you like to order for pickup or delivery?""" 15 | context_manager.add('select-method', lifespan=1) 16 | return ask(speech) 17 | 18 | 19 | def reprompt_method(): 20 | return ask('Sorry, is this order for pickup or delivery?') 21 | 22 | 23 | # Represents the first branching of contexts -> delivery or pickup 24 | @assist.context("select-method") 25 | @assist.action('choose-order-method') 26 | def make_sure(order_method): 27 | context_manager.add(order_method) 28 | speech = "Did you say {}?".format(order_method) 29 | return ask(speech) 30 | 31 | 32 | # Delivery context actions to gather address info 33 | 34 | @assist.context("delivery") 35 | @assist.action('confirm') 36 | def confirm_delivery(answer): 37 | if 'n' in answer: 38 | reprompt_method() 39 | else: 40 | speech = "Ok sounds good. Can I have your address?" 41 | context_manager.add('delivery-info') 42 | return ask(speech) 43 | 44 | 45 | @assist.context("delivery", "delivery-info") 46 | @assist.action('store-address', mapping={'address': 'sys.address'}) 47 | def store_address(address): 48 | speech = "Ok, and can I have your name?" 49 | 50 | context_manager.add('delivery-info', lifespan=10) 51 | context_manager.set('delivery-info', 'address', address) 52 | 53 | return ask(speech) 54 | 55 | 56 | @assist.context("delivery", "delivery-info") 57 | @assist.action('store-name', mapping={'name': 'sys.given-name'}) 58 | def store_phone(name, address): # address can be pulled from existing delivery-info context 59 | speech = """Thanks, {} ... Ok, that's all I need for your delivery info. 60 | With your address being {}, delivery time should be about 20 minutes. 61 | So would you like a special or custom pizza?""".format(name, address) 62 | 63 | context_manager.add('delivery-info', lifespan=10) 64 | context_manager.set('delivery-info', 'name', name) 65 | context_manager.add('build') 66 | 67 | return ask(speech) 68 | 69 | 70 | @assist.context("pickup") 71 | @assist.action('confirm') 72 | def confirm_pickup(answer): 73 | if 'y' in answer: 74 | speech = "Awesome, let's get your order started. Would you like a custom or specialty pizza?" 75 | context_manager.add('build') 76 | return ask(speech) 77 | else: 78 | reprompt_method() 79 | 80 | 81 | @assist.context('build') 82 | @assist.action('begin-order') 83 | def begin_and_set_type(pizza_type): 84 | if pizza_type == 'custom': 85 | speech = "Ok, what size custom pizza would you like?" 86 | else: 87 | speech = 'We have Canadian bacon with pineapple, meat lovers, and vegetarian. Which one would you like?' 88 | pizza_type = 'special' 89 | 90 | context_manager.add('pizza', lifespan=10).set('type', pizza_type) # Store pizza details throughout order 91 | # Set context for which questions you will ask about the pizza 92 | context_manager.add(pizza_type) 93 | 94 | return ask(speech) 95 | 96 | 97 | @assist.context('build', 'special') 98 | @assist.action('choose-special-type') 99 | def set_special_choice(specialty): 100 | speech = 'Cool, you chose a {} pizza. What size do you want?'.format(specialty) 101 | context_manager.add('special').set('specialty', specialty) 102 | 103 | return ask(speech) 104 | 105 | # This action is matched for the set-size intent regardless of pizza-type context 106 | # action params are matched to the corresponding parameter within existing contexts 107 | # if not provided with user's response 108 | 109 | 110 | @assist.context('build') 111 | @assist.action('set-size') 112 | def set_size(size, pizza_type, specialty=None): 113 | if not specialty: 114 | specialty = ' ' 115 | speech = 'Ok, so you want a {} {} {} pizza. Is this correct?'.format(size, specialty, pizza_type) 116 | context_manager.add('size-chosen') # set context for confirming order 117 | return ask(speech) 118 | 119 | 120 | @assist.context('build', 'custom', 'size-chosen') 121 | @assist.action('confirm') 122 | def confirm_and_continue(answer): 123 | if answer.lower() in 'yes': 124 | speech = 'What topping would you like to add? We have pepperoni, bacon, and veggies.' 125 | context_manager.add('toppings', lifespan=4) 126 | return ask(speech) 127 | 128 | else: 129 | return review_pizza() 130 | 131 | 132 | @assist.context('build', 'toppings') 133 | @assist.action('choose-toppings') 134 | def store_toppings(new_topping): 135 | speech = 'Ok, I added {} to your pizza. Add another?'.format(new_topping) 136 | context_manager.get('pizza').set('top1', new_topping) 137 | return ask(speech) 138 | 139 | 140 | 141 | if __name__ == '__main__': 142 | app.run(debug=True) 143 | -------------------------------------------------------------------------------- /api_ai/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import json 4 | 5 | from . import logger 6 | from .models import Intent, Entity 7 | 8 | 9 | class ApiAi(object): 10 | """Interface for making and recieving API-AI requests. 11 | 12 | Use the developer access token for managing entities and intents and the client access token for making queries. 13 | 14 | """ 15 | 16 | def __init__(self, dev_token=None, client_token=None): 17 | 18 | self._dev_token = dev_token or os.getenv("DEV_ACCESS_TOKEN") 19 | self._client_token = client_token or os.getenv("CLIENT_ACCESS_TOKEN") 20 | self.base_url = "https://dialogflow.googleapis.com/v2" 21 | 22 | @property 23 | def _dev_header(self): 24 | if self._dev_token is None: 25 | raise ValueError( 26 | "No Dialogflow dev_token set.\nTokens must be passed to the Assistant() constructor or set as enviornment variable" 27 | ) 28 | return { 29 | "Authorization": "Bearer {}".format(self._dev_token), 30 | "Content-Type": "application/json", 31 | } 32 | 33 | @property 34 | def _client_header(self): 35 | if self._client_token is None: 36 | raise ValueError( 37 | "No Dialogflow client_token set.\nTokens must be passed to Assistant() constructor or set as enviornment variable" 38 | ) 39 | return { 40 | "Authorization": "Bearer {}".format(self._client_token), 41 | "Content-Type": "application/json; charset=utf-8", 42 | } 43 | 44 | def _intent_uri(self, intent_id=""): 45 | if intent_id != "": 46 | intent_id = "/" + intent_id 47 | return "{}intents{}?v={}".format(self.base_url, intent_id, self.versioning) 48 | 49 | def _entity_uri(self, entity_id=""): 50 | if entity_id != "": 51 | entity_id = "/" + entity_id 52 | return "{}entities{}?v={}".format(self.base_url, entity_id, self.versioning) 53 | 54 | @property 55 | def _query_uri(self): 56 | return "{}query?v={}".format(self.base_url, self.versioning) 57 | 58 | def _get(self, endpoint): 59 | response = requests.get(endpoint, headers=self._dev_header) 60 | response.raise_for_status 61 | logger.debug("Response from {}: {}".format(endpoint, response)) 62 | return response.json() 63 | 64 | def _post(self, endpoint, data): 65 | response = requests.post(endpoint, headers=self._dev_header, data=data) 66 | response.raise_for_status 67 | return response.json() 68 | 69 | def _put(self, endpoint, data): 70 | response = requests.put(endpoint, headers=self._dev_header, data=data) 71 | response.raise_for_status 72 | return response.json() 73 | 74 | ## Intents ## 75 | 76 | @property 77 | def agent_intents(self): 78 | """Returns a list of intent json objects""" 79 | endpoint = self._intent_uri() 80 | intents = self._get(endpoint) # should be list of dicts 81 | if isinstance(intents, dict): # if error: intents = {status: {error}} 82 | raise Exception(intents["status"]) 83 | 84 | return [Intent(intent_json=i) for i in intents] 85 | 86 | def get_intent(self, intent_id): 87 | """Returns the intent object with the given intent_id""" 88 | endpoint = self._intent_uri(intent_id=intent_id) 89 | return self._get(endpoint) 90 | 91 | def post_intent(self, intent_json): 92 | """Sends post request to create a new intent""" 93 | endpoint = self._intent_uri() 94 | return self._post(endpoint, data=intent_json) 95 | 96 | def put_intent(self, intent_id, intent_json): 97 | """Send a put request to update the intent with intent_id""" 98 | endpoint = self._intent_uri(intent_id) 99 | return self._put(endpoint, intent_json) 100 | 101 | ## Entities ## 102 | 103 | @property 104 | def agent_entities(self): 105 | """Returns a list of intent json objects""" 106 | endpoint = self._entity_uri() 107 | entities = self._get(endpoint) # should be list of dicts 108 | if isinstance(entities, dict): # error: entities = {status: {error}} 109 | raise Exception(entities["status"]) 110 | 111 | return [Entity(entity_json=i) for i in entities if isinstance(i, dict)] 112 | 113 | def get_entity(self, entity_id): 114 | endpoint = self._entity_uri(entity_id=entity_id) 115 | return self._get(endpoint) 116 | 117 | def post_entity(self, entity_json): 118 | endpoint = self._entity_uri() 119 | return self._post(endpoint, data=entity_json) 120 | 121 | def put_entity(self, entity_id, entity_json): 122 | endpoint = self._entity_uri(entity_id) 123 | return self._put(endpoint, data=entity_json) 124 | 125 | ## Querying ## 126 | def post_query(self, query, sessionID=None): 127 | data = { 128 | "query": query, 129 | "sessionId": sessionID or "123", 130 | "lang": "en", 131 | "contexts": [], 132 | } 133 | 134 | data = json.dumps(data) 135 | 136 | response = requests.post( 137 | self._query_uri, headers=self._client_header, data=data 138 | ) 139 | response.raise_for_status 140 | return response 141 | -------------------------------------------------------------------------------- /docs/source/generate_schema.rst: -------------------------------------------------------------------------------- 1 | ************************ 2 | Generating Dialogflow Schema 3 | ************************ 4 | 5 | .. IMPORTANT:: Schema Generation with Flask-Assistant is not yet implemented for V2 of Dialogflow. Please define intents and entities in the Dialogflow console directly. 6 | 7 | Flask-Assistant provides a command line utilty to automatically generate your agent's JSON schema and register the required information to communicate with Dialogflow. 8 | 9 | This allows you to focus on building your entire webhook from your text editor while needing to interact with the Dialogflow web interface only for testing. 10 | 11 | 12 | The ``schema`` command generates JSON objects representing Intents and Entities 13 | 14 | 15 | Intent Schema 16 | ============= 17 | 18 | When the ``schema`` command is run, Intent objects are created from each of your webhook's action decorated functions. 19 | 20 | 21 | The following information is extracted from your webhook and is included in each intent object: 22 | 23 | - Intent name - from the :any:`@action` decorator 24 | - Action name - the name of the wrapped action function 25 | - Accepted parameters - action function's accepted parameters including their default values and if they are required 26 | 27 | User Says Template 28 | ------------------ 29 | 30 | Additionally, a `User Says `_ template skeleton for each intent is created. 31 | The template will be located within the newly created `templates` directory. 32 | 33 | This template is written in YAML, and each intent is represented by the following structure: 34 | 35 | .. code-block:: yaml 36 | 37 | 38 | intent-name: 39 | UserSays: 40 | - 41 | - 42 | Annotations: 43 | - 44 | - 45 | 46 | Using the template, you can include: 47 | - `Examples `_ of phrases a user might say to trigger the intent 48 | - Annotations as a mapping of paramater values to entity types. 49 | 50 | To provide examples phrases, simply write a phrase using natural language 51 | 52 | .. code-block:: yaml 53 | 54 | order-pizza-intent: 55 | 56 | UserSays: 57 | - I want a small cheese pizza 58 | - large pepporoni pizza for delivery 59 | 60 | You can then annotate parameter values within the phrase to their respective entity 61 | 62 | .. code-block:: language 63 | 64 | order-pizza-intent: 65 | 66 | UserSays: 67 | - I want a small cheese pizza 68 | - large pepperoni pizza for delivery 69 | 70 | Annotations: 71 | - small: pizza-size 72 | - cheese: topping 73 | - pepperoni: topping 74 | - delivery: order-method 75 | 76 | If the intent requires no parameters or you'd like Dialogflow to automaticcaly annotate the phrase, simply exclude the ``Annotations`` or leave it blank. 77 | 78 | Re-running the ``schema`` command will then update your agent's Intents with the new user phrases, including their annotations. 79 | 80 | 81 | 82 | Entity Schema 83 | ============= 84 | 85 | The schema command also allows you to define custom `entities `_ which represent 86 | concepts and serve as a powerful tool for extracting parameter values from natural language inputs. 87 | 88 | In addition to the User Says template, an entities template is generated in the same `templates` directory. 89 | 90 | Entity Template 91 | --------------- 92 | 93 | The basic skeleton will include only the names of your agent's entities, which are taken from action function parameters. 94 | 95 | Using the entities template, you can include: 96 | - The entity name 97 | - A list of entries, which represent a mapping between a reference value and a group of synonyms. 98 | 99 | The basic structure of an entity within the template looks like this: 100 | 101 | .. code-block:: yaml 102 | 103 | toppings: 104 | - 105 | - 106 | 107 | You can provide entries by listing them under the entity name. 108 | 109 | .. code-block:: yaml 110 | 111 | toppings: 112 | - cheese 113 | - ham 114 | - veggies 115 | - pepperoni 116 | 117 | Synonyms can be added for each entry to improve Dialogflow's detection of the entity. 118 | 119 | .. code-block:: yaml 120 | 121 | toppings: 122 | - cheese: ['plain'] 123 | - ham : ['canadian bacon'] 124 | - veggies: ['vegetarian', 'vegetables'] 125 | - pepperoni 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | .. note:: Any pre-built Dialogflow `system entities `_ (sys.color) will not be included in the template, as they are already defined within Dialogflow. 136 | 137 | 138 | 139 | 140 | 141 | Running the command 142 | ========================== 143 | 144 | This will require an existing Dialogflow agent, and your webhook should be within its own directory, as the utility will create two new folders in the app's root. 145 | 146 | 1. First obtain your agent's Developer access token from the `Dialogflow Console`_ 147 | 2. Ensure you are in the same directory as your assistant and store your token as an environment variable 148 | .. code-block:: bash 149 | 150 | export DEV_ACCES_TOKEN='YOUR ACCESS TOKEN' 151 | 3. Run the `schema` command 152 | .. code-block:: bash 153 | 154 | schema my_assistant.py 155 | 156 | This will generate a JSON object for each intent and entity used in your webhook as described above. The schema objects will be pushed to Dialogflow and create a new intent/entity or update the existing one if the object already exists. 157 | 158 | You will see an output of status messages indicating if the registration was successful for each object. 159 | 160 | You can view the JSON generated in the newly created `schema` directory. 161 | 162 | 163 | 164 | .. _`Dialogflow Console`: https://console.dialogflow.com/api-client 165 | -------------------------------------------------------------------------------- /api_ai/models.py: -------------------------------------------------------------------------------- 1 | # coding: utf8 2 | 3 | import json 4 | import re 5 | 6 | class Entity(): 7 | """docstring for Entity""" 8 | 9 | def __init__(self, name=None, entity_json=None): 10 | 11 | if name and not entity_json: 12 | self.name = name 13 | self.entries = [] 14 | self.isEnum = None 15 | self.id = None 16 | 17 | elif entity_json: 18 | self.update(entity_json) 19 | 20 | else: 21 | raise TypeError('Must provide a "name" argument if no json given') 22 | 23 | def add_entry(self, value, synonyms=[]): 24 | if self.isEnum: 25 | entry = {'value': value, 'synonyms': value} 26 | entry = {'value': value, 'synonyms': synonyms} 27 | self.entries.append(entry) 28 | 29 | 30 | def add_synonyms(self, entry, synonyms): 31 | self.entries[entry].extend(synonyms) 32 | 33 | @property 34 | def serialize(self): 35 | return json.dumps(self.__dict__) 36 | 37 | def __repr__(self): 38 | return '@' + self.name 39 | 40 | def update(self, entity_json): 41 | try: 42 | self.__dict__.update(entity_json) 43 | except TypeError: 44 | self.__dict__.update(json.loads(entity_json)) 45 | 46 | 47 | class Intent(): 48 | """Represents an Intent object within the API.AI REST APi. 49 | 50 | Intents are created internally using an Assistant app's action decorated view functions 51 | These objects provide the JSON schema for registering, updating, and removing intents in 52 | the API.AI develoepr console via JSON requests. 53 | """ 54 | 55 | def __init__(self, name=None, priority=500000, fallback_intent=False, contexts=None, events=None, intent_json=None): 56 | 57 | if name and not intent_json: 58 | self.name = name 59 | self.auto = True 60 | self.contexts = contexts or [] 61 | self.templates = [] 62 | self.userSays = [] 63 | self.responses = [] 64 | self.priority = priority 65 | self.fallbackIntent = fallback_intent 66 | self.webhookUsed = True 67 | self.webhookForSlotFilling = True 68 | self.events = Intent._build_events(events) 69 | self.id = None 70 | 71 | elif intent_json: 72 | self.update(intent_json) 73 | 74 | else: 75 | raise TypeError('Must provide a "name" argument if no json given') 76 | 77 | @staticmethod 78 | def _build_events(events): 79 | return [] if events is None else [{'name': event} for event in events] 80 | 81 | def __repr__(self): 82 | return "".format(self.name) 83 | 84 | def registered(self): 85 | if self.id: 86 | return True 87 | 88 | 89 | def add_example(self, phrase, templ_entity_map=None): # TODO 90 | if templ_entity_map: 91 | example = UserDefinedExample(phrase, templ_entity_map) 92 | else: 93 | example = AutoAnnotedExamle(phrase) 94 | 95 | self.userSays.append(example.serialize) 96 | 97 | def add_action(self, action_name, parameters=[]): 98 | self.responses = [{ 99 | 'action': action_name, 100 | 'resetContexts': False, 101 | 'affectedContexts': [], # TODO: register context outs 102 | 'parameters': parameters, 103 | 'messages': [] # TODO: possibly register action responses to call from intent object directly 104 | }] 105 | # self.responses.append(new_response) 106 | 107 | def add_event(self, event_name): 108 | self.events.append({'name': event_name}) 109 | 110 | @property 111 | def serialize(self): 112 | return json.dumps(self.__dict__) 113 | 114 | def update(self, intent_json): 115 | try: 116 | self.__dict__.update(intent_json) 117 | except TypeError: 118 | self.__dict__.update(json.loads(intent_json)) 119 | 120 | 121 | 122 | class ExampleBase(object): 123 | """docstring for ExampleBase""" 124 | 125 | def __init__(self, phrase, user_defined=False, isTemplate=False): 126 | 127 | self.text = phrase 128 | self.userDefined = user_defined 129 | self.isTemplate = isTemplate 130 | self.data = [] 131 | 132 | @property 133 | def serialize(self): 134 | return { 135 | 'data': self.data, 136 | 'isTemplate': self.isTemplate, 137 | 'count': 0 138 | } 139 | 140 | 141 | class AutoAnnotedExamle(ExampleBase): 142 | 143 | def __init__(self, phrase): 144 | super(AutoAnnotedExamle, self).__init__(phrase) 145 | self.text = phrase 146 | self.data.append({'text': self.text, 'userDefined': False}) 147 | 148 | 149 | class UserDefinedExample(ExampleBase): 150 | 151 | def __init__(self, phrase, entity_map): 152 | super(UserDefinedExample, self).__init__(phrase, user_defined=True) 153 | # import ipdb; ipdb.set_trace() 154 | self.entity_map = entity_map 155 | 156 | self._parse_phrase(self.text) 157 | 158 | def _parse_phrase(self, sub_phrase): 159 | if not sub_phrase: 160 | return 161 | 162 | for value in self.entity_map: 163 | re_value = r".\b{}\b".format(value[1:]) if value.startswith(('$', '¥', '¥', '€', '£')) else r"\b{}\b".format(value) 164 | if re.search(re_value, sub_phrase): 165 | parts = sub_phrase.split(value, 1) 166 | self._parse_phrase(parts[0]) 167 | self._annotate_params(value) 168 | self._parse_phrase(parts[1]) 169 | return 170 | 171 | self.data.append({'text': sub_phrase}) 172 | 173 | def _annotate_params(self, word): 174 | """Annotates a given word for the UserSays data field of an Intent object. 175 | 176 | Annotations are created using the entity map within the user_says.yaml template. 177 | """ 178 | annotation = {} 179 | annotation['text'] = word 180 | annotation['meta'] = '@' + self.entity_map[word] 181 | annotation['alias'] = self.entity_map[word].replace('sys.', '') 182 | annotation['userDefined'] = True 183 | self.data.append(annotation) 184 | 185 | -------------------------------------------------------------------------------- /docs/source/hass.rst: -------------------------------------------------------------------------------- 1 | ************************************ 2 | Home Assistant Integration 3 | ************************************ 4 | 5 | Flask-Assistant includes a :class:`HassRemote ` interface to make requests to Home Assistant's `REST api `_. This allows your Dialogflow agent to control and retrieve data about your IoT devices. 6 | 7 | 8 | 9 | Integrating your assistant with Home Assistant is as easy as adding a method call to your action functions. 10 | 11 | 12 | Using the HassRemote 13 | ===================== 14 | 15 | 16 | First import and create an instance of the HassRemote. 17 | 18 | .. code-block:: python 19 | 20 | from flask import Flask 21 | from flask_assistant import Assistant, tell 22 | from flask_assistant.hass import HassRemote 23 | 24 | app = Flask(__name__) 25 | assist = Assistant(app) 26 | hass = HassRemote('YOUR Home Assistant PASSWORD') 27 | 28 | Sending Requests to Home Assistant 29 | ---------------------------------- 30 | 31 | 32 | The HassRemote is a simple wrapper around Home Assistant's own `remote `_ module. The remote module can be used in the same way to control Home Assistant. ``HassRemote`` just provides a set of methods for commonly sent requests specific to entitiy domain. These methods will often accept the same paramter as the action function itself, allowing clean and more cohesive code within action functions. 33 | 34 | 35 | .. important:: Each of these methods require the entity_id parameter. The name of this parameter should be the same as the Home Assistant domain. 36 | 37 | For example: 38 | 39 | If you have a switch in your HA configuration with the name "switch.coffee_maker", the name of the parameter should be "switch". This allows your entities to be properly defined within your entities.yaml template when generating schema. 40 | 41 | 42 | Controlling Lights 43 | ^^^^^^^^^^^^^^^^^^ 44 | 45 | .. code-block:: python 46 | 47 | @assist.action('turn-on-light') 48 | def light_on(light, brightness=255): 49 | speech = 'Turning on {} to {}'.format(light, brightness) 50 | hass.turn_on_light(light, brightness) 51 | return tell(speech) 52 | 53 | @assist.action('turn-off-light') 54 | def light_off(light): 55 | speech = 'Turning off {}'.format(light) 56 | hass.turn_off_light(light) 57 | return tell(speech) 58 | 59 | 60 | Flip a Switch 61 | ^^^^^^^^^^^^^^ 62 | 63 | .. code-block:: python 64 | 65 | @assist.action('toggle-switch') 66 | def toggle(switch): 67 | speech = 'Toggling switch for {}'.format(switch) 68 | hass.switch(switch) 69 | return tell(speech) 70 | 71 | @assist.action('switch-on') 72 | def switch_on(switch): 73 | speech = 'Flipping on {} switch'.format(switch) 74 | hass.switch(switch, service='turn_on') 75 | return tell(speech) 76 | 77 | 78 | Starting HA Scripts 79 | ^^^^^^^^^^^^^^^^^^^ 80 | 81 | .. code-block:: python 82 | 83 | @assist.action('start-script') 84 | def start(script): 85 | speech = 'Running {}'.format('script.{}'.format(script)) 86 | hass.start_script(script) 87 | return tell(speech) 88 | 89 | Running Shell Commands 90 | ^^^^^^^^^^^^^^^^^^^^^^ 91 | 92 | .. code-block:: python 93 | 94 | @assist.action('run-command') 95 | def run(shell_command): 96 | speech = 'Running the {} shell command'.format(shell_command) 97 | hass.command(shell_command) 98 | return tell(speech) 99 | 100 | 101 | .. Controlling Groups 102 | .. ------------------ 103 | 104 | .. .. code-block:: python 105 | 106 | .. @assist.action('turn-on-group') 107 | .. def turn_on_group(group, brightness=255): 108 | .. speech = 'Turning on {} to {} brightness'.format(group, brightness) 109 | .. hass.call_service('light', 'turn_on', {'entity_id': 'group.{}'.format(group), brightness: brightness}) 110 | .. return tell(speech) 111 | 112 | 113 | Hass Entity Templates 114 | ====================== 115 | 116 | Home Assistant devices used within action functions can easily be included in your entries template, and are automatically added with the when :doc:`generating schema `. 117 | 118 | 119 | Although Home Assistant and Dialogflow both use the term entities, they are used in slightly different ways. 120 | 121 | Home Assistant: 122 | - uses the term entity to describe any device or service connected to HA. 123 | - Each entity belongs to a domain (component). 124 | 125 | Dialogflow: 126 | - Uses the term entity to describe a concept that is used within actions 127 | - Each instance of the entity is called an entry, and may be the value of parameters required by actions 128 | 129 | Therefore, the idea of a ``HA entity`` is similar to an ``Dialogflow entry``. 130 | 131 | So HA devices can be defined as entries under their domain, with their domain serving as the Dialogflow entity. 132 | 133 | .. code-block:: yaml 134 | 135 | domain: 136 | - device1: [synonyms] 137 | - device2: [synonyms] 138 | 139 | Template Examples 140 | ----------------- 141 | 142 | A Group of Lights: 143 | ^^^^^^^^^^^^^^^^^^ 144 | 145 | .. code-block:: yaml 146 | 147 | light: 148 | - lamp_1: ['ceiling light', 'fan light', 'main light'] 149 | - lamp_2: ['lamp', 'desk lamp'] 150 | - lamp_3: ['bedroom light', 'room light', 'bedroom'] 151 | - room: ['all lights', 'lights', 'room'] # a group within home assistant 152 | 153 | Within Home Assistant lamp_2 would be identified as light.lamp_2 and room as light.room 154 | 155 | 156 | Switches 157 | ^^^^^^^^ 158 | 159 | .. code-block:: yaml 160 | 161 | switch: 162 | - coffee_maker: ['coffee', 'brew', 'coffee machine'] 163 | - playstation4: ['ps4', 'playstation'] 164 | - stereo: ['sound', 'sound system'] 165 | 166 | 167 | Scripts 168 | ^^^^^^^^ 169 | 170 | .. code-block:: yaml 171 | 172 | script: 173 | - flash_lights: ['flash', 'flash the lights', 'strobe'] 174 | - party_mode: ['bump it up', 'start the party'] 175 | 176 | Shell Commands 177 | ^^^^^^^^^^^^^^ 178 | 179 | .. code-block:: yaml 180 | 181 | 182 | shell_command: 183 | - playstation_netflix_start: ['netflix', 'netflix on the ps4'] 184 | - playstation_overwatch_start: [overwatch] 185 | - playstation_gtav_start: [gta five, gta] 186 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Flask-Assistant documentation build configuration file, created by 5 | # sphinx-quickstart on Wed Jan 18 17:49:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('...')) 23 | sys.path.append(os.path.abspath('_themes')) 24 | 25 | 26 | 27 | # -- General configuration ------------------------------------------------ 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = ['sphinx.ext.autodoc', 37 | 'sphinx.ext.doctest', 38 | 'sphinx.ext.intersphinx', 39 | 'sphinx.ext.todo', 40 | 'sphinx.ext.coverage', 41 | 'sphinx.ext.viewcode', 42 | # 'sphinx.ext.githubpages', 43 | # 'flaskdocext' 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # General information about the project. 59 | project = 'Flask-Assistant' 60 | copyright = '2017, Cam Sweeney' 61 | author = 'Cam Sweeney' 62 | 63 | # The version info for the project you're documenting, acts as replacement for 64 | # |version| and |release|, also used in various other places throughout the 65 | # built documents. 66 | # 67 | # The short X.Y version. 68 | version = '0.0.9' 69 | # The full version, including alpha/beta/rc tags. 70 | release = '0.0.9' 71 | 72 | # The language for content autogenerated by Sphinx. Refer to documentation 73 | # for a list of supported languages. 74 | # 75 | # This is also used if you do content translation via gettext catalogs. 76 | # Usually you set "language" from the command line for these cases. 77 | language = None 78 | 79 | # List of patterns, relative to source directory, that match files and 80 | # directories to ignore when looking for source files. 81 | # This patterns also effect to html_static_path and html_extra_path 82 | exclude_patterns = [] 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # If true, `todo` and `todoList` produce output, else they produce nothing. 88 | todo_include_todos = True 89 | 90 | 91 | # -- Options for HTML output ---------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | # 96 | html_theme_path = ['_themes'] 97 | html_theme = 'alabaster' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | # 103 | html_theme_options = { 104 | 'github_user': 'treethought', 105 | 'github_repo': 'flask-assistant', 106 | 'github_button': 'true', 107 | 'logo': 'logo-xs.png', 108 | 'logo_name': 'true', 109 | 'description': 'Virtual Assistants with Python', 110 | 'sidebar_width': '280px', 111 | # 'extra_nav_links': 'true', 112 | 'page_width': '1020px' 113 | 114 | } 115 | 116 | html_sidebars = { 117 | '**': [ 118 | 'about.html', 119 | 'navigation.html', 120 | 'relations.html', 121 | 'searchbox.html', 122 | 'donate.html', 123 | # 'stayinformed.html' 124 | ] 125 | } 126 | # html_sidebars = { 127 | # 'index': ['globaltoc.html'], 128 | # '**': ['sidebarlogo.html', 'globaltoc.html'] 129 | # } 130 | 131 | # Add any paths that contain custom static files (such as style sheets) here, 132 | # relative to this directory. They are copied after the builtin static files, 133 | # so a file named "default.css" will overwrite the builtin "default.css". 134 | html_static_path = ['_static'] 135 | 136 | 137 | # -- Options for HTMLHelp output ------------------------------------------ 138 | 139 | # Output file base name for HTML help builder. 140 | htmlhelp_basename = 'Flask-Assistantdoc' 141 | 142 | 143 | # -- Options for LaTeX output --------------------------------------------- 144 | 145 | latex_elements = { 146 | # The paper size ('letterpaper' or 'a4paper'). 147 | # 148 | # 'papersize': 'letterpaper', 149 | 150 | # The font size ('10pt', '11pt' or '12pt'). 151 | # 152 | # 'pointsize': '10pt', 153 | 154 | # Additional stuff for the LaTeX preamble. 155 | # 156 | # 'preamble': '', 157 | 158 | # Latex figure (float) alignment 159 | # 160 | # 'figure_align': 'htbp', 161 | } 162 | 163 | # Grouping the document tree into LaTeX files. List of tuples 164 | # (source start file, target name, title, 165 | # author, documentclass [howto, manual, or own class]). 166 | latex_documents = [ 167 | (master_doc, 'Flask-Assistant.tex', 'Flask-Assistant Documentation', 168 | 'Cam Sweeney', 'manual'), 169 | ] 170 | 171 | 172 | # -- Options for manual page output --------------------------------------- 173 | 174 | # One entry per manual page. List of tuples 175 | # (source start file, name, description, authors, manual section). 176 | man_pages = [ 177 | (master_doc, 'flask-assistant', 'Flask-Assistant Documentation', 178 | [author], 1) 179 | ] 180 | 181 | 182 | # -- Options for Texinfo output ------------------------------------------- 183 | 184 | # Grouping the document tree into Texinfo files. List of tuples 185 | # (source start file, target name, title, author, 186 | # dir menu entry, description, category) 187 | texinfo_documents = [ 188 | (master_doc, 'Flask-Assistant', 'Flask-Assistant Documentation', 189 | author, 'Flask-Assistant', 'One line description of project.', 190 | 'Miscellaneous'), 191 | ] 192 | 193 | 194 | 195 | 196 | # Example configuration for intersphinx: refer to the Python standard library. 197 | intersphinx_mapping = {'https://docs.python.org/': None} 198 | -------------------------------------------------------------------------------- /samples/actions_demo/webhook.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | from flask import Flask 4 | from flask_assistant import Assistant, ask, tell, build_item, context_manager, event 5 | 6 | 7 | app = Flask(__name__) 8 | assist = Assistant(app) 9 | logging.getLogger("flask_assistant").setLevel(logging.DEBUG) 10 | 11 | 12 | app.config["INTEGRATIONS"] = ["ACTIONS_ON_GOOGLE"] 13 | 14 | ASSIST_LOGO_URL = "http://flask-assistant.readthedocs.io/en/latest/_static/logo-xs.png" 15 | ASSIST_REPO_URL = "https://github.com/treethought/flask-assistant" 16 | ASSIST_DOCS_URL = "https://flask-assistant.readthedocs.io/en/latest/" 17 | ASSIST_DESCRIPT = """Flask-Assistant allows you to focus on building the core business logic 18 | of conversational user interfaces while utilizing Dialogflow’s 19 | Natural Language Processing to interact with users.""" 20 | 21 | ASK_LOGO_URL = "https://alexatutorial.com/flask-ask/_images/logo-full.png" 22 | ASK_REPO_URL = "https://github.com/johnwheeler/flask-ask" 23 | ASK_DOCS_URL = "https://alexatutorial.com/flask-ask/" 24 | ASK_DESCRIPT = "Flask-Ask is a Flask extension that makes building Alexa skills for the Amazon Echo easier and much more fun." 25 | 26 | FLASK_LOGO_URL = "http://flask.pocoo.org/static/logo/flask.svg" 27 | FLASK_REPO_URL = "https://github.com/pallets/flask" 28 | FLASK_DOCS_URL = "http://flask.pocoo.org/docs/0.12/" 29 | FLASK_DESCRIPT = """Flask is a microframework for Python based on Werkzeug 30 | and Jinja2. It's intended for getting started very quickly 31 | and was developed with best intentions in mind.""" 32 | 33 | DIALOG_LOGO_URL = "https://www.gstatic.com/devrel-devsite/v38663d710eee65508ba32a8cb54a4950be119532cae3e02de8cb04cf45f67558/dialogflow/images/lockup.svg" 34 | DIALOG_DOCS_URL = "https://dialogflow.com/docs" 35 | DIALOG_DESCRIPT = """Dialogflow is a natural language understanding platform 36 | that makes it easy for developers (and non-developers) 37 | to design and integrate intelligent and sophisticated 38 | conversational user interfaces into mobile apps, 39 | web applications, devices, and bots.""" 40 | 41 | 42 | @assist.action("Default Welcome Intent") 43 | def welcome(): 44 | speech = "Welcome to Flask-Assistant on Google Assistant! Try Asking to see a card!" 45 | resp = ask(speech) 46 | return resp.suggest("Show card", "show list") 47 | return ( 48 | ask(speech) 49 | .reprompt("Do you want to see some examples?") 50 | .suggest("Show card", "Show List") 51 | ) 52 | 53 | 54 | @assist.action("Default Welcome Intent - yes") 55 | def action_func(): 56 | speech = """This is just a simple text to speech message. 57 | Ask to see a card!""" 58 | 59 | return ask(speech).suggest("Show card", "Show List") 60 | 61 | 62 | @assist.action("ShowCard") 63 | def show_card(): 64 | 65 | # Basic speech/text response 66 | resp = ask("Now ask to see a list...") 67 | 68 | # Now add a card onto basic response 69 | resp.card( 70 | text=ASSIST_DESCRIPT, 71 | title="Flask-Assistant", 72 | subtitle="Create Virtual Assistants with python", 73 | img_url=ASSIST_LOGO_URL, 74 | ) 75 | 76 | # Suggest other intents 77 | resp.suggest("Show List", "Show Carousel") 78 | 79 | # Provide links to outside sources 80 | resp.link_out("Github", ASSIST_REPO_URL) 81 | 82 | return resp 83 | 84 | 85 | @assist.action("ShowList") 86 | def action_func(): 87 | 88 | # Checking if option is selected 89 | if (context_manager.get("actions_intent_option")) is not None: 90 | option = context_manager.get("actions_intent_option").get( 91 | "OPTION" 92 | ) # getting the key sent 93 | if option == "flask_assistant": 94 | return event("assistCarousel") # returning events 95 | elif option == "flask_ask": 96 | return event("fAsk") # returning events 97 | elif option == "flask": 98 | return event("fCard") # returning events 99 | 100 | # Basic speech/text response 101 | resp = ask("Select Flask-Assistant for a carousel") 102 | 103 | # Create a list with a title 104 | mylist = resp.build_list("Awesome List") 105 | 106 | # Add items directly to list 107 | mylist.add_item( 108 | "Flask-Assistant", 109 | key="flask_assistant", # query sent if item selected 110 | img_url=ASSIST_LOGO_URL, 111 | description="Select for carousel", 112 | synonyms=["flask assistant", "number one", "assistant", "carousel"], 113 | ) 114 | 115 | mylist.add_item( 116 | "Flask-Ask", 117 | key="flask_ask", 118 | img_url=ASK_LOGO_URL, 119 | description="Rapid Alexa Skills Kit Development for Amazon Echo Devices", 120 | synonyms=["ask", "flask ask", "number two"], 121 | ) 122 | 123 | # Or build items independent of list 124 | flask_item = build_item( 125 | "Flask", 126 | key="flask", 127 | img_url=FLASK_LOGO_URL, 128 | description="A microframework for Python based on Werkzeug, Jinja 2 and good intentions", 129 | synonyms=["flask", "number three"], 130 | ) 131 | 132 | # and add them to the lsit later 133 | mylist.include_items(flask_item) 134 | 135 | return mylist 136 | 137 | 138 | @assist.action("FlaskAssistantCarousel") 139 | def action_func(): 140 | resp = ask("Heres some info on Flask-Assistant and Dialogflow").build_carousel() 141 | 142 | resp.add_item( 143 | "Overview", key="overview", description=ASSIST_DESCRIPT, img_url=ASSIST_LOGO_URL 144 | ) 145 | 146 | resp.add_item( 147 | "Dialogflow", 148 | key="dialogflow", 149 | description=DIALOG_DESCRIPT, 150 | img_url=DIALOG_LOGO_URL, 151 | ) 152 | return resp 153 | 154 | 155 | @assist.action("FlaskAskCard") 156 | def action_func(): 157 | resp = ask("Many thanks to Flask-Ask and John Wheeler") 158 | resp.card( 159 | text=ASK_DESCRIPT, title="Flask-Ask", img_url=ASK_LOGO_URL, link=ASK_DOCS_URL 160 | ) 161 | 162 | # Provide links to outside sources 163 | resp.link_out("View on Github", ASK_REPO_URL) 164 | resp.link_out("Read the Docs", ASK_DOCS_URL) 165 | 166 | return resp 167 | 168 | 169 | @assist.action("FlaskCard") 170 | def action_func(): 171 | resp = ask("The one and only Flask") 172 | resp.card( 173 | text=FLASK_DESCRIPT, title="Flask", img_url=FLASK_LOGO_URL, link=FLASK_DOCS_URL 174 | ) 175 | 176 | # Provide links to outside sources 177 | resp.link_out("View on Github", FLASK_REPO_URL) 178 | resp.link_out("Read the Docs", FLASK_DOCS_URL) 179 | 180 | return resp 181 | 182 | 183 | if __name__ == "__main__": 184 | app.run(debug=True) 185 | -------------------------------------------------------------------------------- /docs/source/contexts.rst: -------------------------------------------------------------------------------- 1 | 2 | ************** 3 | Using Context 4 | ************** 5 | 6 | Overview 7 | ======== 8 | 9 | Flask-assitant supports API.AI's concept of `contexts `_. 10 | 11 | Contexts help to store and persist accessible information over multiple requests and define the "state" of the current session. 12 | You can create different contexts based on the actions your assistant performs and use the generated contexts to determine which intents may be triggered (and thus which actions may take place) in future requests. 13 | 14 | The use of contexts allows for a more dynamic dialogue and is helpful for differentiating phrases which may be vague or have different meanings depending on the user’s preferences or geographic location, the current page in an app, or the topic of conversation. 15 | 16 | Intents may require input contexts, and their actions may set output contexts. 17 | 18 | 19 | Context Objects 20 | =============== 21 | 22 | Input Contexts 23 | 24 | - Input contexts limit intents to be matched only when certain contexts are set. 25 | - They essentially act as requirements for a particular intent's action function to be called. 26 | - They are received in every request from API.AI under a "contexts" element, and consist of any previously declared output contexts 27 | 28 | Output Contexts 29 | 30 | - Output contexts are set by actions to share information across requests within a session and are received as Input Contexts for future intents. 31 | - If an input context is modified within in action, the changes will persist via a new output context. 32 | 33 | In a REST-like fashion, all declared contexts are received in every request from API.AI and included in every response from your assistant. Flask-assistant provides the :any:`context_manager` to automatically handle this exchange and preserve the state of the conversation. 34 | 35 | 36 | 37 | .. Flask-assistant provides two mechanisms for utilizing contexts to build dialogues: the :any:`context_manager` and :meth:`@context ` 38 | 39 | 40 | Context Manager 41 | ================================== 42 | 43 | 44 | 45 | The :any:`context_manager` is used to declare, access, and modify context objects. It contains the input contexts recieved from the API.AI request and appends any new or modified contexts to the flask-assistant response. 46 | 47 | 48 | .. It is available as a `LocalProxy `_ and 49 | 50 | 51 | .. code-block:: python 52 | 53 | from flask_assistant import context_manager 54 | 55 | 56 | Add a new context: 57 | 58 | .. code-block:: python 59 | 60 | context_manager.add('context-name') 61 | 62 | 63 | Retrieve a declared context: 64 | 65 | .. code-block:: python 66 | 67 | my_context = context_manager.get('context-name') 68 | 69 | 70 | .. Set a parameter value directly on a context object... 71 | 72 | .. .. code-block:: python 73 | 74 | .. my_context.set('foo', bar) 75 | 76 | Set a parameter value: 77 | 78 | .. code-block:: python 79 | 80 | context_manager.set('context-name', 'param_name', value) 81 | 82 | 83 | context decorator 84 | ================== 85 | 86 | The :meth:`context` decorator restricts a wrapped action function to be matched only if the given contexts are active. 87 | 88 | While the :meth:`context_manager` is used create and access context objects, the :meth:`context` decorator is responsible for mapping an intent to one of possibly many context-dependent action functions. 89 | 90 | The basic :meth:`action` intent-mapping in conjuction with :meth:`context` action filtering allows 91 | a single intent to invoke an action appropriate to the current conversation. 92 | 93 | For example: 94 | 95 | .. code-block:: python 96 | 97 | @assist.action('give-diet') 98 | def set_user_diet(diet): 99 | speech = 'Are you trying to make food or get food?' 100 | context_manager.add(diet) 101 | return ask(speech) 102 | 103 | @assist.context('vegetarian') 104 | @assist.action('get-food') 105 | def suggest_food(): 106 | return tell("There's a farmers market tonight.") 107 | 108 | @assist.context('carnivore') 109 | @assist.action('get-food') 110 | def suggest_food(): 111 | return tell("Bob's BBQ has some great tri tip") 112 | 113 | @assist.context('broke') 114 | @assist.action('get-food') 115 | def suggest_food(): 116 | return tell("Del Taco is open late") 117 | 118 | 119 | 120 | .. Example 121 | .. ======= 122 | 123 | .. Let's edit the `choose-order-type` action function from the :doc:`quick_start` to set a context 124 | 125 | 126 | .. .. code-block:: python 127 | 128 | .. from flask_assistant import context_manager 129 | 130 | .. @assist.action('choose-order-type') 131 | .. def set_order_context(order_type): 132 | .. speech = "Did you say {}?".format(order_type) 133 | .. context_manager.add(order_type) 134 | .. return ask(speech) 135 | 136 | 137 | .. Now we'll use the incoming context to match a single intent to one of two action functions depending on their required contexs. 138 | .. The following set of actions represent a branching of the dialogue into two seperate contexts: delivery or pickup 139 | 140 | .. .. The following confirm actions will then be matched depending on the order_type context provided from the previous action 141 | 142 | .. .. code-block:: python 143 | 144 | .. # will be matched if user said 'pickup' 145 | .. @assist.context("pickup") 146 | .. @assist.action('confirm') 147 | .. def confirm_pickup(answer): 148 | .. if 'no' in answer: 149 | .. order_type_prompt() 150 | .. else: 151 | .. speech = "Awesome, would you like to pick up a specialty or custom pizza?" 152 | .. context_manager.add('build') 153 | .. return ask(speech) 154 | 155 | .. A conversation specific to the 'pickup' context won't require any delivery address information, so the above action adds a 'build' context to transition to the next state of the dialogue: building the pizza 156 | 157 | .. However, the 'delivery' conversation will require this information, so it sets a 'delivery-info' context so that the assistant will prompt for the required delivery information before proceeding to building the pizza. 158 | 159 | 160 | .. .. code-block:: python 161 | 162 | .. # will be matched if user said 'delivery' 163 | .. @assist.context("delivery") 164 | .. @assist.action('confirm') 165 | .. def confirm_delivery(answer): 166 | .. if 'no' in answer: 167 | .. order_type_prompt() 168 | .. else: 169 | .. speech = "Ok sounds good. Can I have your address?" 170 | .. context_manager.add('delivery-info') 171 | .. return ask(speech) 172 | 173 | 174 | 175 | 176 | .. Storing Paramater Values in Contexts 177 | .. ==================================== 178 | 179 | .. We can also use the `context_manager` to store and retrieve values required at later actions. 180 | 181 | .. .. code-block:: python 182 | 183 | .. # set the param directly using the context object 184 | .. my_context = context_manager.get(context_name) 185 | .. my_context.set(param_name, value) 186 | 187 | .. # or set the param through the context manager 188 | .. context_manager.set(context_name, param_name, value) 189 | 190 | 191 | 192 | .. For example we can store a value for the number of toppings on a custom pizza. 193 | 194 | .. .. code-block:: python 195 | 196 | .. @assist.context('custom') 197 | .. @assist.action('add_toppings') 198 | .. def store_value(num_toppings): 199 | .. charge = (num_toppings * .75) / 100 200 | .. context_manager.set('custom', 'num_toppings', num_toppings) 201 | .. speech = '{} toppings will cost {}. Is that ok?'.format(num_toppings, charge) 202 | .. return ask(speech) 203 | 204 | .. Later, we can retrieve the parameter value 205 | 206 | .. @assist.context('custom', 'checkout') 207 | .. @assist.action('finish-order') 208 | .. def give_total(): 209 | 210 | 211 | .. context_manager.get('finish=checkout') 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | .. Note that each action also added a new context, which can be used in conjuction with existing contexts to provide more precise intent mapping. 223 | 224 | 225 | .. For example, imagine that later in the dialogue we want give the user the total price of their pizza. This will depend on which contexts have been activated: 226 | .. - pickup or delivery 227 | .. - custom or specialty pizza 228 | .. - number of toppings (only applicable to custom pizzas) 229 | 230 | .. Calculating the price could be accomplished like this: 231 | 232 | .. @assist.contex('pickup', 'custom' ) 233 | .. @assist.action('get-price') 234 | .. def calc_price(): 235 | 236 | 237 | -------------------------------------------------------------------------------- /docs/source/responses.rst: -------------------------------------------------------------------------------- 1 | ******************* 2 | Rendering Responses 3 | ******************* 4 | 5 | Conversations are primarily driven by an Assistant's response to the user. Responses not only present the user with the outcome with of the tiggered action, but also control the dialogue by instigating the user to provide intents in a logical manner. 6 | 7 | Flask-Assisant provides three primary response types as well as platform-specific rich message types. 8 | 9 | 10 | Primary Types 11 | ============= 12 | 13 | The primary responses include :any:`ask`, :any:`tell`, and :any:`event`. All rich messages extend the `ask` and `tell` constructs. 14 | 15 | To import the repsonse types: 16 | 17 | .. code-block:: python 18 | 19 | from flask_assistant import ask, tell, event, build_item 20 | 21 | 22 | ask 23 | --- 24 | 25 | To ask a question which expects a response from the user: 26 | 27 | .. code-block:: python 28 | 29 | @assist.action('Kickit') 30 | def kick_it(): 31 | return ask('Can I kick it?') 32 | 33 | 34 | tell 35 | ---- 36 | 37 | To return a text/speech response to the user and end the session: 38 | 39 | .. code-block:: python 40 | 41 | @assist.action('Answer') 42 | def answer(): 43 | return tell('Yes you can!') 44 | 45 | event 46 | ----- 47 | 48 | To invoke another intent directly and bypass an exchange with the user, an :any:`event` can be triggered. 49 | 50 | Assuming the intent "GoOnThen" contains an event named "first_verse", triggering the "Begin" intent will provide the user with the question "'Before this, did you really know what life was?" 51 | 52 | 53 | .. code-block:: python 54 | 55 | 56 | @assist.action('GoOnThen') 57 | def first_verse(): 58 | return ask('Before this, did you really know what life was?') 59 | 60 | @assist.action('Begin') 61 | def start_verse(): 62 | return event('first_verse') 63 | 64 | .. note:: The name of an intent's action function does not necessarily need to share the name of the intent's event, though it may often make sense and provide a cleaner representation of dialogue structure. 65 | 66 | Currently, `Events`_ must be defined within an Intent in the Dialogflow console. 67 | But support for event definitions is coming soon 68 | 69 | Rich Messages 70 | ============= 71 | 72 | In addidtion to the primary text/speech responses, Flask-Assistant plans to provide `Rich Messages`_ for various platforms. 73 | 74 | Currently, Rich Messages are only support for Actions on Google. 75 | 76 | Rich Messages for Actions on Google 77 | ==================================== 78 | 79 | By utlizing the following rich responses, an Assistant can easily integreate with Actions on Google and provide a greater experience on devices that support Google Assistant (Google Home and mobile phones). 80 | 81 | To enable Actions on Google Integration: 82 | 83 | .. code-block:: python 84 | 85 | app.config['INTEGRATIONS'] = ['ACTIONS_ON_GOOGLE'] 86 | 87 | Displaying a Card 88 | ----------------- 89 | 90 | Use a `Card`_ to present the user with summaries or concise information, and to allow users to learn more if you choose (using a weblink). 91 | 92 | - Image 93 | - Title 94 | - Sub-title 95 | - Text body 96 | - Link 97 | 98 | The only information required for a card is the `text` paramter which is used to fill the text body. 99 | 100 | .. code-block:: python 101 | 102 | 103 | @assist.action('ShowCard') 104 | def show_card(): 105 | 106 | resp = ask("Here's an example of a card") 107 | 108 | resp.card(text='The text to display', 109 | title='Card Title', 110 | img_url='http://example.com/image.png' 111 | ) 112 | 113 | return resp 114 | 115 | 116 | Suggesting Other Intents 117 | ------------------------ 118 | 119 | Provide the user with a `Suggestion Chip`_ to hint at responses to continue or pivot the conversation. 120 | The suggestion text is sent as a query to Dialogflow when selected and therefore should match a *User Says* phrase for the intent to be triggered. 121 | 122 | So given the following intents: 123 | 124 | .. code-block:: yaml 125 | 126 | HelpIntent: 127 | UserSays: 128 | - Get Help 129 | - help 130 | 131 | Restart: 132 | Usersays: 133 | - start over 134 | 135 | GetArtistInfo: 136 | Usersays: 137 | - radiohead 138 | - violent femmes 139 | - the books 140 | 141 | Annotations: 142 | - radiohead: artist 143 | - 'the books': artist 144 | 145 | 146 | 147 | 148 | 149 | Provide suggestions for likely intents: 150 | 151 | .. code-block:: python 152 | 153 | @assist.action('SuggestThings') 154 | def suggest_things(): 155 | return ask('What's up?').suggest('help', 'start over', 'radiohead') 156 | 157 | 158 | 159 | Linking to External Resources 160 | ----------------------------- 161 | 162 | In addition to suggestion chips for guiding dialogue, `link_out` chips can be used to send the user to external URLS. 163 | 164 | .. code-block:: python 165 | 166 | @assist.action('ShowResources') 167 | def link_resources(): 168 | resp = ask('Need some external help?') 169 | 170 | resp.link_out('Github Repo', 'https://github.com/treethought/flask-assistant') 171 | resp.link_out('Read The Docs', 'http://flask-assistant.readthedocs.io/en/latest/') 172 | 173 | 174 | List Selectors 175 | ----------------------- 176 | Lists present the user with a vertical list of multiple items and allows the user to select a single one. 177 | Selecting an item from the list generates a user query (chat bubble) containing the title of the list item. This user query will be used to match an agent's intent just like any other query. 178 | 179 | .. note:: There seems to be a discrepency bewteen Dialogflow and Actions on Google in regards to the selection of list items. 180 | Within the Dialogflow console, the items `key` is sent as the user query. However, Actions on Google sends the item's title. 181 | 182 | For proper results within both platforms, simply provide both the item's key and title as `User Says` phrase until the issue is resolved. 183 | 184 | 185 | First, create primary response 186 | 187 | .. code-block:: python 188 | 189 | @assist.action('ShowList') 190 | def action_func(): 191 | 192 | # Basic speech/text response 193 | resp = ask("Here is an example list") 194 | 195 | Then create a list with a title and assign to variable 196 | 197 | .. code-block:: python 198 | 199 | # Create a list with a title and assign to variable 200 | mylist = resp.build_list("Awesome List") 201 | 202 | 203 | Add items directly to list 204 | 205 | .. code-block:: python 206 | 207 | mylist.add_item(title="Option 1", # title sent as query for Actions 208 | key="option_1", 209 | img_url="http://example.com/image1.png", 210 | description="Option 1's short description", 211 | synonyms=['one', 'number one', 'first option']) 212 | 213 | mylist.add_item(title="Option 2", 214 | key="option_2", # key sent as query for Dialogflow 215 | img_url="http://example.com/image2.png", 216 | description="Option 2's short description", 217 | synonyms=['two', 'number two', 'second option']) 218 | 219 | 220 | Or build items independent of list and add them to the list later 221 | 222 | .. code-block:: python 223 | 224 | new_item = build_item(title="Option 3", 225 | key="option_3", # key sent as query for Dialogflow 226 | img_url="http://example.com/image3.png", 227 | description="Option 3's short description", 228 | synonyms=['three', 'number three', third option']) 229 | 230 | mylist.include_items(new_item) 231 | 232 | return mylist 233 | 234 | .. WARNING:: Creating a list with `build_list` returns an instance of a new response class. Therfore the result is a serpeate object than the primary response used to call the `build_list` method. 235 | 236 | The original primary response (*ask*/*tell*) object will not contain the list, and so the result should likely be assigned to a variable. 237 | 238 | 239 | Carousels 240 | --------- 241 | 242 | `Carousels`_ scroll horizontally and allows for selecting one item. They are very similar to list items, but provide richer content by providing multiple tiles resembling cards. 243 | 244 | To build a carousel: 245 | 246 | .. code-block:: python 247 | 248 | @assist.action('FlaskAssistantCarousel') 249 | def action_func(): 250 | resp = ask("Here's a basic carousel").build_carousel() 251 | 252 | resp.add_item("Option 1 Title", 253 | key="option_1", 254 | description='Option 1's longer description, 255 | img_url="http://example.com/image1.png") 256 | 257 | resp.add_item("Option 2 Title", 258 | key="option_2", 259 | description='Option 2's longer description, 260 | img_url="http://example.com/image2.png") 261 | return resp 262 | 263 | 264 | 265 | 266 | 267 | .. _`Events`: https://dialogflow.com/docs/events 268 | .. _`Rich Messages`: https://dialogflow.com/docs/intents/rich-messages 269 | .. _`Card`: https://developers.google.com/actions/assistant/responses#basic_card 270 | .. _`Suggestion Chip`: https://developers.google.com/actions/assistant/responses#suggestion-chip 271 | .. _`Lists`: https://developers.google.com/actions/assistant/responses#list_selector 272 | .. _`Carousels`: https://developers.google.com/actions/assistant/responses#carousel_selector 273 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2016 John Wheeler 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /docs/source/_themes/flask/static/flasky.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | {% set page_width = '940px' %} 10 | {% set sidebar_width = '220px' %} 11 | 12 | @import url("basic.css"); 13 | 14 | /* -- page layout ----------------------------------------------------------- */ 15 | 16 | body { 17 | font-family: 'Georgia', serif; 18 | font-size: 17px; 19 | background-color: white; 20 | color: #000; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | div.document { 26 | width: {{ page_width }}; 27 | margin: 30px auto 0 auto; 28 | } 29 | 30 | div.documentwrapper { 31 | float: left; 32 | width: 100%; 33 | } 34 | 35 | div.bodywrapper { 36 | margin: 0 0 0 {{ sidebar_width }}; 37 | } 38 | 39 | div.sphinxsidebar { 40 | width: {{ sidebar_width }}; 41 | } 42 | 43 | hr { 44 | border: 1px solid #B1B4B6; 45 | } 46 | 47 | div.body { 48 | background-color: #ffffff; 49 | color: #3E4349; 50 | padding: 0 30px 0 30px; 51 | } 52 | 53 | img.floatingflask { 54 | padding: 0 0 10px 10px; 55 | float: right; 56 | } 57 | 58 | div.footer { 59 | width: {{ page_width }}; 60 | margin: 20px auto 30px auto; 61 | font-size: 14px; 62 | color: #888; 63 | text-align: right; 64 | } 65 | 66 | div.footer a { 67 | color: #888; 68 | } 69 | 70 | div.related { 71 | display: none; 72 | } 73 | 74 | div.sphinxsidebar a { 75 | color: #444; 76 | text-decoration: none; 77 | border-bottom: 1px dotted #999; 78 | } 79 | 80 | div.sphinxsidebar a:hover { 81 | border-bottom: 1px solid #999; 82 | } 83 | 84 | div.sphinxsidebar { 85 | font-size: 14px; 86 | line-height: 1.5; 87 | } 88 | 89 | div.sphinxsidebarwrapper { 90 | padding: 18px 10px; 91 | } 92 | 93 | div.sphinxsidebarwrapper p.logo { 94 | padding: 0 0 6px 0; 95 | margin: 0; 96 | } 97 | 98 | div.sphinxsidebar h3, 99 | div.sphinxsidebar h4 { 100 | font-family: 'Garamond', 'Georgia', serif; 101 | color: #444; 102 | font-size: 24px; 103 | font-weight: normal; 104 | margin: 0 0 5px 0; 105 | padding: 0; 106 | } 107 | 108 | div.sphinxsidebar h4 { 109 | font-size: 20px; 110 | } 111 | 112 | div.sphinxsidebar h3 a { 113 | color: #444; 114 | } 115 | 116 | div.sphinxsidebar p.logo a, 117 | div.sphinxsidebar h3 a, 118 | div.sphinxsidebar p.logo a:hover, 119 | div.sphinxsidebar h3 a:hover { 120 | border: none; 121 | } 122 | 123 | div.sphinxsidebar p { 124 | color: #555; 125 | margin: 10px 0; 126 | } 127 | 128 | div.sphinxsidebar ul { 129 | margin: 10px 0; 130 | padding: 0; 131 | color: #000; 132 | } 133 | 134 | div.sphinxsidebar input { 135 | border: 1px solid #ccc; 136 | font-family: 'Georgia', serif; 137 | font-size: 1em; 138 | } 139 | 140 | /* -- body styles ----------------------------------------------------------- */ 141 | 142 | a { 143 | color: #004B6B; 144 | text-decoration: underline; 145 | } 146 | 147 | a:hover { 148 | color: #6D4100; 149 | text-decoration: underline; 150 | } 151 | 152 | div.body h1, 153 | div.body h2, 154 | div.body h3, 155 | div.body h4, 156 | div.body h5, 157 | div.body h6 { 158 | font-family: 'Garamond', 'Georgia', serif; 159 | font-weight: normal; 160 | margin: 30px 0px 10px 0px; 161 | padding: 0; 162 | } 163 | 164 | {% if theme_index_logo %} 165 | div.indexwrapper h1 { 166 | text-indent: -999999px; 167 | background: url({{ theme_index_logo }}) no-repeat center center; 168 | height: {{ theme_index_logo_height }}; 169 | } 170 | {% endif %} 171 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } 172 | div.body h2 { font-size: 180%; } 173 | div.body h3 { font-size: 150%; } 174 | div.body h4 { font-size: 130%; } 175 | div.body h5 { font-size: 100%; } 176 | div.body h6 { font-size: 100%; } 177 | 178 | a.headerlink { 179 | color: #ddd; 180 | padding: 0 4px; 181 | text-decoration: none; 182 | } 183 | 184 | a.headerlink:hover { 185 | color: #444; 186 | background: #eaeaea; 187 | } 188 | 189 | div.body p, div.body dd, div.body li { 190 | line-height: 1.4em; 191 | } 192 | 193 | div.admonition { 194 | background: #fafafa; 195 | margin: 20px -30px; 196 | padding: 10px 30px; 197 | border-top: 1px solid #ccc; 198 | border-bottom: 1px solid #ccc; 199 | } 200 | 201 | div.admonition tt.xref, div.admonition a tt { 202 | border-bottom: 1px solid #fafafa; 203 | } 204 | 205 | dd div.admonition { 206 | margin-left: -60px; 207 | padding-left: 60px; 208 | } 209 | 210 | div.admonition p.admonition-title { 211 | font-family: 'Garamond', 'Georgia', serif; 212 | font-weight: normal; 213 | font-size: 24px; 214 | margin: 0 0 10px 0; 215 | padding: 0; 216 | line-height: 1; 217 | } 218 | 219 | div.admonition p.last { 220 | margin-bottom: 0; 221 | } 222 | 223 | div.highlight { 224 | background-color: white; 225 | } 226 | 227 | dt:target, .highlight { 228 | background: #FAF3E8; 229 | } 230 | 231 | div.note { 232 | background-color: #eee; 233 | border: 1px solid #ccc; 234 | } 235 | 236 | div.seealso { 237 | background-color: #ffc; 238 | border: 1px solid #ff6; 239 | } 240 | 241 | div.topic { 242 | background-color: #eee; 243 | padding: 0 7px 7px 7px; 244 | } 245 | 246 | p.admonition-title { 247 | display: inline; 248 | } 249 | 250 | p.admonition-title:after { 251 | content: ":"; 252 | } 253 | 254 | pre, tt { 255 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 256 | font-size: 0.9em; 257 | } 258 | 259 | img.screenshot { 260 | } 261 | 262 | tt.descname, tt.descclassname { 263 | font-size: 0.95em; 264 | } 265 | 266 | tt.descname { 267 | padding-right: 0.08em; 268 | } 269 | 270 | img.screenshot { 271 | -moz-box-shadow: 2px 2px 4px #eee; 272 | -webkit-box-shadow: 2px 2px 4px #eee; 273 | box-shadow: 2px 2px 4px #eee; 274 | } 275 | 276 | table.docutils { 277 | border: 1px solid #888; 278 | -moz-box-shadow: 2px 2px 4px #eee; 279 | -webkit-box-shadow: 2px 2px 4px #eee; 280 | box-shadow: 2px 2px 4px #eee; 281 | } 282 | 283 | table.docutils td, table.docutils th { 284 | border: 1px solid #888; 285 | padding: 0.25em 0.7em; 286 | } 287 | 288 | table.field-list, table.footnote { 289 | border: none; 290 | -moz-box-shadow: none; 291 | -webkit-box-shadow: none; 292 | box-shadow: none; 293 | } 294 | 295 | table.footnote { 296 | margin: 15px 0; 297 | width: 100%; 298 | border: 1px solid #eee; 299 | background: #fdfdfd; 300 | font-size: 0.9em; 301 | } 302 | 303 | table.footnote + table.footnote { 304 | margin-top: -15px; 305 | border-top: none; 306 | } 307 | 308 | table.field-list th { 309 | padding: 0 0.8em 0 0; 310 | } 311 | 312 | table.field-list td { 313 | padding: 0; 314 | } 315 | 316 | table.footnote td.label { 317 | width: 0px; 318 | padding: 0.3em 0 0.3em 0.5em; 319 | } 320 | 321 | table.footnote td { 322 | padding: 0.3em 0.5em; 323 | } 324 | 325 | dl { 326 | margin: 0; 327 | padding: 0; 328 | } 329 | 330 | dl dd { 331 | margin-left: 30px; 332 | } 333 | 334 | blockquote { 335 | margin: 0 0 0 30px; 336 | padding: 0; 337 | } 338 | 339 | ul, ol { 340 | margin: 10px 0 10px 30px; 341 | padding: 0; 342 | } 343 | 344 | pre { 345 | background: #eee; 346 | margin: 12px 0px; 347 | padding: 11px 14px; 348 | line-height: 1.3em; 349 | } 350 | 351 | dl pre, blockquote pre, li pre { 352 | margin-left: -60px; 353 | padding-left: 60px; 354 | } 355 | 356 | dl dl pre { 357 | margin-left: -90px; 358 | padding-left: 90px; 359 | } 360 | 361 | tt { 362 | background-color: #ecf0f3; 363 | color: #222; 364 | /* padding: 1px 2px; */ 365 | } 366 | 367 | tt.xref, a tt { 368 | background-color: #FBFBFB; 369 | border-bottom: 1px solid white; 370 | } 371 | 372 | a.reference { 373 | text-decoration: none; 374 | border-bottom: 1px dotted #004B6B; 375 | } 376 | 377 | a.reference:hover { 378 | border-bottom: 1px solid #6D4100; 379 | } 380 | 381 | a.footnote-reference { 382 | text-decoration: none; 383 | font-size: 0.7em; 384 | vertical-align: top; 385 | border-bottom: 1px dotted #004B6B; 386 | } 387 | 388 | a.footnote-reference:hover { 389 | border-bottom: 1px solid #6D4100; 390 | } 391 | 392 | a:hover tt { 393 | background: #EEE; 394 | } 395 | 396 | small { 397 | font-size: 0.9em; 398 | } 399 | 400 | 401 | @media screen and (max-width: 870px) { 402 | 403 | div.sphinxsidebar { 404 | display: none; 405 | } 406 | 407 | div.document { 408 | width: 100%; 409 | 410 | } 411 | 412 | div.documentwrapper { 413 | margin-left: 0; 414 | margin-top: 0; 415 | margin-right: 0; 416 | margin-bottom: 0; 417 | } 418 | 419 | div.bodywrapper { 420 | margin-top: 0; 421 | margin-right: 0; 422 | margin-bottom: 0; 423 | margin-left: 0; 424 | } 425 | 426 | ul { 427 | margin-left: 0; 428 | } 429 | 430 | .document { 431 | width: auto; 432 | } 433 | 434 | .footer { 435 | width: auto; 436 | } 437 | 438 | .bodywrapper { 439 | margin: 0; 440 | } 441 | 442 | .footer { 443 | width: auto; 444 | } 445 | 446 | .github { 447 | display: none; 448 | } 449 | 450 | 451 | 452 | } 453 | 454 | 455 | 456 | @media screen and (max-width: 875px) { 457 | 458 | body { 459 | margin: 0; 460 | padding: 20px 30px; 461 | } 462 | 463 | div.documentwrapper { 464 | float: none; 465 | background: white; 466 | } 467 | 468 | div.sphinxsidebar { 469 | display: block; 470 | float: none; 471 | width: 102.5%; 472 | margin: 50px -30px -20px -30px; 473 | padding: 10px 20px; 474 | background: #333; 475 | color: white; 476 | } 477 | 478 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, 479 | div.sphinxsidebar h3 a { 480 | color: white; 481 | } 482 | 483 | div.sphinxsidebar a { 484 | color: #aaa; 485 | } 486 | 487 | div.sphinxsidebar p.logo { 488 | display: none; 489 | } 490 | 491 | div.document { 492 | width: 100%; 493 | margin: 0; 494 | } 495 | 496 | div.related { 497 | display: block; 498 | margin: 0; 499 | padding: 10px 0 20px 0; 500 | } 501 | 502 | div.related ul, 503 | div.related ul li { 504 | margin: 0; 505 | padding: 0; 506 | } 507 | 508 | div.footer { 509 | display: none; 510 | } 511 | 512 | div.bodywrapper { 513 | margin: 0; 514 | } 515 | 516 | div.body { 517 | min-height: 0; 518 | padding: 0; 519 | } 520 | 521 | .rtd_doc_footer { 522 | display: none; 523 | } 524 | 525 | .document { 526 | width: auto; 527 | } 528 | 529 | .footer { 530 | width: auto; 531 | } 532 | 533 | .footer { 534 | width: auto; 535 | } 536 | 537 | .github { 538 | display: none; 539 | } 540 | } 541 | 542 | 543 | /* scrollbars */ 544 | 545 | ::-webkit-scrollbar { 546 | width: 6px; 547 | height: 6px; 548 | } 549 | 550 | ::-webkit-scrollbar-button:start:decrement, 551 | ::-webkit-scrollbar-button:end:increment { 552 | display: block; 553 | height: 10px; 554 | } 555 | 556 | ::-webkit-scrollbar-button:vertical:increment { 557 | background-color: #fff; 558 | } 559 | 560 | ::-webkit-scrollbar-track-piece { 561 | background-color: #eee; 562 | -webkit-border-radius: 3px; 563 | } 564 | 565 | ::-webkit-scrollbar-thumb:vertical { 566 | height: 50px; 567 | background-color: #ccc; 568 | -webkit-border-radius: 3px; 569 | } 570 | 571 | ::-webkit-scrollbar-thumb:horizontal { 572 | width: 50px; 573 | background-color: #ccc; 574 | -webkit-border-radius: 3px; 575 | } 576 | 577 | /* misc. */ 578 | 579 | .revsys-inline { 580 | display: none!important; 581 | } 582 | -------------------------------------------------------------------------------- /docs/source/quick_start.rst: -------------------------------------------------------------------------------- 1 | *********** 2 | Quick Start 3 | *********** 4 | 5 | This page will provide a walk through of making a basic assistant 6 | 7 | Installation 8 | ============ 9 | .. code-block:: bash 10 | 11 | pip install flask-assistant 12 | 13 | Setting Up the Project 14 | ====================== 15 | 16 | Create a directory to serve as the app root (useful if auto-generating Intent schema) 17 | 18 | .. code-block:: bash 19 | 20 | mkdir my_assistant 21 | cd my_assistant 22 | 23 | touch webhook.py 24 | 25 | 26 | Server Setup 27 | ------------ 28 | This example will use ``ngrok`` to quickly provide a public URL for the flask-assistant webhook. This is required for Dialogflow to communicate with the assistant app. 29 | 30 | Make sure you have `ngrok`_ installed and start an http instance on port 5000. 31 | 32 | - .. code-block:: bash 33 | 34 | ./ngrok http 5000 35 | 36 | A status message similiar to the one below will be shown. 37 | 38 | :: 39 | 40 | ngrok by @inconshreveable (Ctrl+C to quit) 41 | 42 | Session Status online 43 | Version 2.1.18 44 | Region United States (us) 45 | Web Interface http://127.0.0.1:4040 46 | Forwarding http://1ba714e7.ngrok.io -> localhost:5000 47 | Forwarding https://1ba714e7.ngrok.io -> localhost:5000 48 | 49 | Note the **Forwarding https** URL. 50 | - ``https://1ba714e7.ngrok.io`` in the above example. 51 | - This is the URL that will be used as the Webhook URL in the Dialogflow console as described below. 52 | 53 | 54 | .. _api_setup: 55 | 56 | Dialogflow Setup 57 | ------------ 58 | 59 | 1. Sign in to the `Dialogflow Console`_ 60 | 2. Create a new Agent_ named "HelloWorld" and click save. 61 | 3. Click on Fullfillment in the left side menu and enable webhook. 62 | 4. Provide the ``https`` URL from the `ngrok` status message as the webhook URL. 63 | 64 | .. 5. Create a new project in the `Google Developer Console`_ 65 | 66 | 67 | .. Step 5 is not required for test your app within the Dialogflow console, but is if you plan to test or deploy on Google Home 68 | 69 | 70 | .. note:: You can create new intents and provide information about their action and parameters 71 | in the web interface and they will still be matched to your assistant's action function for the intent's name. 72 | 73 | However, it may often be simpler to define your intents directly from your assistant as will be shown here. 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Create your Webhook 82 | ==================== 83 | 84 | Create a directory to serve as the app root. 85 | 86 | .. code-block:: bash 87 | 88 | mkdir my_assistant 89 | cd my_assistant 90 | 91 | Create a a new file for your assistant's webhook 92 | 93 | .. code-block:: bash 94 | 95 | touch webhook.py 96 | 97 | 98 | 99 | In your new webhook.py file: 100 | 101 | 102 | .. code-block:: python 103 | 104 | from flask import Flask 105 | from flask_assistant import Assistant, ask, tell 106 | 107 | app = Flask(__name__) 108 | assist = Assistant(app, route='/') 109 | 110 | 111 | @assist.action('greeting') 112 | def greet_and_start(): 113 | speech = "Hey! Are you male or female?" 114 | return ask(speech) 115 | 116 | if __name__ == '__main__': 117 | app.run(debug=True) 118 | 119 | Here, we have defined an action function to be called if the 'greeting' intent is matched. 120 | The action function returns a response to Dialogflow which greets the user and asks the user for their gender. 121 | 122 | Now let's define the action to be performed when the user provides their gender. 123 | 124 | 125 | .. code-block:: python 126 | 127 | @assist.action("give-gender") 128 | def ask_for_color(gender): 129 | if gender == 'male': 130 | gender_msg = 'Sup bro!' 131 | else: 132 | gender_msg = 'Haay gurl!' 133 | 134 | speech = gender_msg + ' What is your favorite color?' 135 | return ask(speech) 136 | 137 | When the user gives their gender as a response to the ``greet_and_start`` action, it matches the `give-gender` intent and triggers the ``ask_for_color`` action. 138 | 139 | The gender value will be parsed as an `entity `_ from the user's phrase, identified as a parameter and passed to the action function. 140 | 141 | In order for the gender to be recognized by Dialogflow, we will need to :ref:`define and register ` an entity with Dialogflow. 142 | 143 | 144 | Before we define our entity, let's first finish the webhook by defining the final action, which will occur after the user provides their favorite color. 145 | 146 | .. code-block:: python 147 | 148 | @assist.action('give-color', mapping={'color': 'sys.color'}) 149 | def ask_for_season(color): 150 | speech = 'Ok, {} is an okay color I guess'.format(color) 151 | return ask(speech) 152 | 153 | 154 | Because this action requires the ``color`` parameter, a color entity needs to be defined within our Dialogflow agent. 155 | However, there are a very large number of colors that we'd like our Dialogflow to recognize as a color entity. 156 | 157 | Instead of defining our own ``color`` entity and all of the possible entries for the entity (as we will do with ``gender``), we will utilize one of Dialogflow's `System Entities `_. 158 | 159 | To do this we simply mapped the ``color`` parameter to the `sys.color` System Entity: 160 | 161 | .. code-block:: python 162 | 163 | @assist.action('give-color', mapping={'color': 'sys.color'}) 164 | 165 | .. This allows flask-assistant to grab the value of ``color`` from the 166 | 167 | Now we do not need to provide any definition about the ``color`` entity, and Dialogflow will automaticlly recognize any color spoken by the user to be parsed as a ``sys.color`` entity. 168 | 169 | 170 | 171 | 172 | 173 | .. _schema: 174 | 175 | Registering Schema 176 | =================================== 177 | At this point our assistant app has three intents: ``greeting`` and ``give-gender`` and ``give-color``. 178 | They are defined with the :meth:`action ` decorator, but how does Dialogflow know that these intents exist and how does it know what the user should say to match them? 179 | 180 | Flask-assistant includes a command line utilty to automatically create and register required schema with Dialogflow. 181 | 182 | Let's walk through how to utilize the :doc:`schema ` command. 183 | 184 | 185 | 186 | 187 | 188 | Run the schema command 189 | ---------------------- 190 | 191 | 1. First obtain your agent's Access Tokens from the `Dialogflow Console`_. 192 | 2. Ensure you are in the same directory as your assistant and store your token as an environment variable 193 | .. code-block:: bash 194 | 195 | cd my_assistant 196 | export DEV_ACCESS_TOKEN='YOUR DEV TOKEN' 197 | export CLIENT_ACCESS_TOKEN='YOUR CLIENT TOKEN' 198 | 199 | 3. Run the `schema` command 200 | .. code-block:: bash 201 | 202 | schema webhook.py 203 | 204 | The ``schema`` command will then output the result of registering intents and entities. 205 | 206 | With regards to the intent registration: 207 | :: 208 | 209 | Generating intent schema... 210 | 211 | Registering greeting intent 212 | {'status': {'errorType': 'success', 'code': 200}, 'id': 'be697c8a-539d-4905-81f2-44032261f715'} 213 | 214 | Registering give-gender intent 215 | {'status': {'errorType': 'success', 'code': 200}, 'id': '9759acde-d5f4-4552-940c-884dbcd8c615'} 216 | 217 | Writing schema json to file 218 | 219 | Navigate to your agent's Intents section within the `Dialogflow Console`_. You will now see that the ``greeting``, ``give-gender`` and ``give-color`` intents have been registered. 220 | 221 | However, if you click on the ``give-gender`` intent, you'll see an error pop-up message that the `gender` entity hasn't been created. This is expected from the ``schema`` output message for the entities registration: 222 | 223 | :: 224 | Generating entity schema... 225 | 226 | Registering gender entity 227 | {'timestamp': '2017-02-01T06:09:03.489Z', 'id': '0d7e278d-84e3-4ba8-a617-69e9b240d3b4', 228 | 'status': {'errorType': 'bad_request', 'code': 400, 'errorDetails': "Error adding entity. Error in entity 'gender'. Entry value is empty, this entry will be skipped. . ", 'errorID': '21f62e16-4e07-405b-a201-e68f8930a88d'}} 229 | 230 | To fix this, we'll use the templates created from the schema command to provide more compelete schema. 231 | 232 | 233 | 234 | Using the schema Templates 235 | -------------------------- 236 | 237 | The schema command creates a new `templates/` directory containing two YAML template skeletons: 238 | 239 | ``user_says.yaml`` is used to: 240 | - Define phrases a user will say to match specific intents 241 | - Annotate parameters within the phrases as specific entity types 242 | 243 | ``entities.yaml`` is used to: 244 | - Define `entities`_ 245 | - Provide entries (examples of the entity type) and their synonyms 246 | 247 | Entity Template 248 | ^^^^^^^^^^^^^^^^ 249 | 250 | Let's edit `templates/entities.yaml` to provide the needed schema to register the gender entity. 251 | 252 | Initially, the template will contain a simple declaration of the entity names, but will be missing the entities' entries. 253 | 254 | .. code-block:: yaml 255 | 256 | gender: 257 | - 258 | - 259 | 260 | Entries represent a mapping between a reference value and a group of synonyms. Let's add the appropriate entries for the gender entity. 261 | 262 | .. code-block:: yaml 263 | 264 | gender: 265 | - male: ['man', 'boy', 'guy', 'dude'] 266 | - female: ['woman', 'girl', 'gal'] 267 | 268 | .. note:: Any pre-built Dialogflow system entities (sys.color) will not be included in the template, as they are already defined within Dialogflow. 269 | 270 | .. _user_says_templ: 271 | 272 | User Says Template 273 | ^^^^^^^^^^^^^^^^^^ 274 | 275 | Now we will fill in the `templates/user_says.yaml` template to provide examples of what the user may say to trigger our defined intents. 276 | 277 | After running the ``schema`` command, the User Says Template will include a section for each intent. 278 | 279 | 280 | For example, the give-color intent will look like: 281 | 282 | .. code-block:: yaml 283 | 284 | 285 | give-color: 286 | UserSays: 287 | - 288 | - 289 | Annotations: 290 | - 291 | - 292 | 293 | To fill in the template, provide exmaples of what the user may say under ``UserSays`` and a mapping of paramater value to entity type under ``Annotations``. 294 | 295 | .. code-block:: yaml 296 | 297 | give-color: 298 | 299 | UserSays: 300 | - my color is blue 301 | - Its blue 302 | - I like red 303 | - My favorite color is red 304 | - blue 305 | 306 | Annotations: 307 | - blue: sys.color 308 | - red: sys.color 309 | 310 | 311 | give-gender: 312 | 313 | UserSays: 314 | - male 315 | - Im a female 316 | - girl 317 | 318 | Annotations: 319 | - male: gender 320 | - female: gender 321 | - girl: gender 322 | 323 | If the intent requires no parameters or you'd like Dialogflow to automatically annotate the phrase, simply exclude the ``Annotations`` or leave it blank. 324 | 325 | .. code-block:: yaml 326 | 327 | greeting: 328 | UserSays: 329 | - hi 330 | - hello 331 | - start 332 | - begin 333 | - launch 334 | 335 | 336 | 337 | Now that the templates are filled out, run the schema command again to update exsting Intents schema and register the newly defined `gender` entity. 338 | 339 | .. code-block:: bash 340 | 341 | schema webhook.py 342 | 343 | Testing the Assistant 344 | ===================== 345 | 346 | Now that the schema has been registered with Dialogflow, we can make sure everything is working. 347 | 348 | Add the following to set up logging so that we can see the Dialogflow request and flask-assistant response JSON. 349 | 350 | .. code-block:: python 351 | 352 | import logging 353 | logging.getLogger('flask_assistant').setLevel(logging.DEBUG) 354 | 355 | .. code-block:: bash 356 | 357 | python webhook.py 358 | 359 | You can now interact with your assistant using the `Try it now..` area on the right hand side of the `Dialogflow Console`_. 360 | 361 | 362 | 363 | Integrate with Actions on Google 364 | ================================= 365 | 366 | With the webhook logic complete and the Dialogflow agent set up, you can now easily 367 | integrate with Actions on Google. This will allow you to preview and deploy your assistant on Google Home. 368 | 369 | To integrate with Actions on Google, follow this simple `guide `_ from Dialogflow. 370 | 371 | More info on how to integrate your assistant with various platforms can be found `here `_. 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | .. _`entities`: https://dialogflow.com/docs/entities 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | .. _ 414 | 415 | .. _`Dialogflow Console`: https://console.dialogflow.com 416 | .. _`Agent`: https://console.dialogflow.com/api-client/#/newAgent 417 | .. _`Google Developer Console`: https://console.developers.google.com/projectselector/apis/api/actions.googleapis.com/overview 418 | .. _`Flask-Live-Starter`: https://github.com/johnwheeler/flask-live-starter 419 | .. _`ngrok`: https://ngrok.com/ 420 | -------------------------------------------------------------------------------- /api_ai/schema_handlers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import inspect 3 | import json 4 | from ruamel import yaml 5 | 6 | from .models import Intent, Entity 7 | 8 | 9 | 10 | 11 | class SchemaHandler(object): 12 | 13 | def __init__(self, assist, object_type=None): 14 | 15 | self.assist = assist 16 | self.intents = [] 17 | self.api = assist.api 18 | self.object_type = object_type 19 | 20 | # File set up 21 | 22 | def get_or_create_dir(self, dir_name): 23 | d = os.path.join(self.assist.app.root_path, dir_name) 24 | if not os.path.isdir(d): 25 | os.mkdir(d) 26 | return d 27 | 28 | @property 29 | def schema_dir(self): 30 | return self.get_or_create_dir('schema') 31 | 32 | @property 33 | def json_file(self): 34 | file_name = '{}.json'.format(self.object_type) 35 | f = os.path.join(self.schema_dir, file_name) 36 | if not os.path.isfile(f): 37 | open(f, 'w+').close() 38 | return f 39 | 40 | @property 41 | def saved_schema(self): 42 | with open(self.json_file, 'r') as f: 43 | try: 44 | return json.load(f) 45 | except ValueError as e: # python2 46 | return [] 47 | except json.decoder.JSONDecodeError: # python3 48 | return [] 49 | 50 | 51 | @property 52 | def registered(self): 53 | if self.saved_schema: 54 | return [i for i in self.saved_schema if i if i.get('id')] 55 | 56 | def dump_schema(self, schema): 57 | print('Writing schema json to file') 58 | with open(self.json_file, 'w') as f: 59 | json.dump(schema, f, indent=4) 60 | 61 | # templates 62 | @property 63 | def template_dir(self): 64 | return self.get_or_create_dir('templates') 65 | 66 | def template_file(self, template_type): 67 | file_name = '{}.yaml'.format(template_type) 68 | f = os.path.join(self.template_dir, file_name) 69 | if not os.path.isfile(f): 70 | open(f, 'w+').close() 71 | return f 72 | 73 | @property 74 | def user_says_template(self): 75 | return self.template_file('user_says') 76 | 77 | @property 78 | def entity_template(self): 79 | return self.template_file('entities') 80 | 81 | 82 | def load_yaml(self, template_file): 83 | with open(template_file) as f: 84 | try: 85 | return yaml.safe_load(f) 86 | except yaml.YAMLError as e: 87 | print(e) 88 | return [] 89 | 90 | def user_says_yaml(self): 91 | return self.load_yaml(self.user_says_template) 92 | 93 | def entity_yaml(self): 94 | return self.load_yaml(self.entity_template) 95 | 96 | 97 | 98 | def grab_id(self, obj_name): 99 | if self.registered: 100 | for obj in self.registered: 101 | if obj['name'] == obj_name: 102 | return obj['id'] 103 | 104 | 105 | class IntentGenerator(SchemaHandler): 106 | 107 | def __init__(self, assist): 108 | super(IntentGenerator, self).__init__(assist, object_type='intents') 109 | 110 | 111 | @property 112 | def app_intents(self): 113 | """Returns a list of Intent objects created from the assistant's acion functions""" 114 | from_app = [] 115 | for intent_name in self.assist._intent_action_funcs: 116 | intent = self.build_intent(intent_name) 117 | from_app.append(intent) 118 | return from_app 119 | 120 | def build_intent(self, intent_name): 121 | """Builds an Intent object of the given name""" 122 | # TODO: contexts 123 | is_fallback = self.assist._intent_fallbacks[intent_name] 124 | contexts = self.assist._required_contexts[intent_name] 125 | events = self.assist._intent_events[intent_name] 126 | new_intent = Intent(intent_name, fallback_intent=is_fallback, contexts=contexts, events=events) 127 | self.build_action(new_intent) 128 | self.build_user_says(new_intent) # TODO 129 | return new_intent 130 | 131 | 132 | def build_action(self, intent): 133 | action_name = self.assist._intent_action_funcs[intent.name][0].__name__ 134 | params = self.parse_params(intent.name) 135 | intent.add_action(action_name, parameters=params) 136 | 137 | def parse_params(self, intent_name): 138 | """Parses params from an intent's action decorator and view function. 139 | 140 | Returns a list of parameter field dicts to be included in the intent object's response field. 141 | """ 142 | 143 | params = [] 144 | action_func = self.assist._intent_action_funcs[intent_name][0] 145 | argspec = inspect.getargspec(action_func) 146 | param_entity_map = self.assist._intent_mappings.get(intent_name) 147 | 148 | args, defaults = argspec.args, argspec.defaults 149 | default_map = {} 150 | if defaults: 151 | default_map = dict(zip(args[-len(defaults):], defaults)) 152 | 153 | # import ipdb; ipdb.set_trace() 154 | for arg in args: 155 | param_info = {} 156 | 157 | param_entity = param_entity_map.get(arg, arg) 158 | param_name = param_entity.replace('sys.', '') 159 | # param_name = arg 160 | 161 | param_info['name'] = param_name 162 | param_info['value'] = '$' + param_name 163 | param_info['dataType'] = '@' + param_entity 164 | param_info['prompts'] = [] # TODO: fill in provided prompts 165 | param_info['required'] = arg not in default_map 166 | param_info['isList'] = isinstance(default_map.get(arg), list) 167 | if param_info['isList']: 168 | param_info['defaultValue'] = '' 169 | else: 170 | param_info['defaultValue'] = default_map.get(arg, '') 171 | 172 | params.append(param_info) 173 | return params 174 | 175 | def get_synonyms(self, annotation, entity): 176 | raw_temp = self.entity_yaml() 177 | for temp_dict in [d for d in raw_temp if d == entity]: 178 | for entry in raw_temp.get(temp_dict): 179 | if isinstance(entry, dict): 180 | for a, s in entry.items(): 181 | if a == annotation: 182 | for synonym in s: 183 | yield(synonym) 184 | 185 | def build_user_says(self, intent): 186 | raw = self.user_says_yaml() 187 | intent_data = raw.get(intent.name) 188 | 189 | if intent_data: 190 | phrases = intent_data.get('UserSays', []) 191 | annotations = intent_data.get('Annotations', []) 192 | events = intent_data.get('Events', []) 193 | mapping = {} 194 | for a in [a for a in annotations if a]: 195 | for annotation, entity in a.items(): 196 | mapping.update({str(annotation):str(entity)}) 197 | for synonym in self.get_synonyms(annotation, entity): 198 | mapping.update({str(synonym):str(entity)}) 199 | 200 | for phrase in [p for p in phrases if p]: 201 | if phrase != '': 202 | intent.add_example(phrase, templ_entity_map=mapping) 203 | 204 | for event in [e for e in events if e]: 205 | intent.add_event(event) 206 | 207 | 208 | def push_intent(self, intent): 209 | """Registers or updates an intent and returns the intent_json with an ID""" 210 | if intent.id: 211 | print('Updating {} intent'.format(intent.name)) 212 | self.update(intent) 213 | else: 214 | print('Registering {} intent'.format(intent.name)) 215 | intent = self.register(intent) 216 | return intent 217 | 218 | def register(self, intent): 219 | """Registers a new intent and returns the Intent object with an ID""" 220 | response = self.api.post_intent(intent.serialize) 221 | print(response) 222 | print() 223 | if response['status']['code'] == 200: 224 | intent.id = response['id'] 225 | elif response['status']['code'] == 409: # intent already exists 226 | intent.id = next(i.id for i in self.api.agent_intents if i.name == intent.name) 227 | self.update(intent) 228 | return intent 229 | 230 | def update(self, intent): 231 | response = self.api.put_intent(intent.id, intent.serialize) 232 | print(response) 233 | print() 234 | if response['status']['code'] == 200: 235 | return response 236 | 237 | def generate(self): 238 | print('Generating intent schema...') 239 | schema = [] 240 | for intent in self.app_intents: 241 | intent.id = self.grab_id(intent.name) 242 | intent = self.push_intent(intent) 243 | schema.append(intent.__dict__) 244 | self.dump_schema(schema) 245 | 246 | 247 | class EntityGenerator(SchemaHandler): 248 | 249 | def __init__(self, assist): 250 | super(EntityGenerator, self).__init__(assist, object_type='entities') 251 | 252 | def build_entities(self): 253 | raw_temp = self.entity_yaml() 254 | 255 | for entity_name in raw_temp: 256 | e = Entity(entity_name) 257 | self.build_entries(e, raw_temp) 258 | yield e 259 | 260 | def build_entries(self, entity, temp_dict): 261 | entries = temp_dict.get(entity.name, []) 262 | for entry in entries: 263 | if isinstance(entry, dict): # mapping 264 | (value, synyms), = entry.items() 265 | else: # enum/composite 266 | entity.isEnum = True 267 | value = entry 268 | synyms = [entry] 269 | entity.add_entry(value, synyms) 270 | 271 | def register(self, entity): 272 | """Registers a new entity and returns the entity object with an ID""" 273 | response = self.api.post_entity(entity.serialize) 274 | print(response) 275 | print() 276 | if response['status']['code'] == 200: 277 | entity.id = response['id'] 278 | if response['status']['code'] == 409: # entity already exists 279 | entity.id = next(i.id for i in self.api.agent_entities if i.name == entity.name) 280 | self.update(entity) 281 | return entity 282 | 283 | def update(self, entity): 284 | response = self.api.put_entity(entity.id, entity.serialize) 285 | print(response) 286 | print() 287 | if response['status']['code'] == 200: 288 | return response 289 | 290 | def push_entity(self, entity): 291 | """Registers or updates an entity and returns the entity_json with an ID""" 292 | if entity.id: 293 | print('Updating {} entity'.format(entity.name)) 294 | self.update(entity) 295 | else: 296 | print('Registering {} entity'.format(entity.name)) 297 | entity = self.register(entity) 298 | return entity 299 | 300 | def generate(self): 301 | print('Generating entity schema...') 302 | schema = [] 303 | for entity in self.build_entities(): 304 | entity.id = self.grab_id(entity.name) 305 | entity = self.push_entity(entity) 306 | schema.append(entity.__dict__) 307 | self.dump_schema(schema) 308 | 309 | 310 | 311 | class TemplateCreator(SchemaHandler): 312 | 313 | def __init__(self, assist): 314 | super(TemplateCreator, self).__init__(assist) 315 | 316 | self.assist = assist 317 | 318 | def generate(self): 319 | if not self.user_says_yaml(): 320 | self.create_user_says_skeleton() 321 | if not self.entity_yaml(): 322 | self.create_entity_skeleton() 323 | 324 | def get_or_create_dir(self, dir_name): 325 | try: 326 | root = self.assist.app.root_path 327 | except AttributeError: # for blueprints 328 | root = self.assist.blueprint.root_path 329 | 330 | d = os.path.join(root, dir_name) 331 | 332 | if not os.path.isdir(d): 333 | os.mkdir(d) 334 | return d 335 | 336 | @property 337 | def template_dir(self): 338 | return self.get_or_create_dir('templates') 339 | 340 | @property 341 | def user_says_exists(self): 342 | return self._user_says_exists 343 | 344 | 345 | def parse_annotations_from_action_mappings(self, intent_name): 346 | annotations = [] 347 | entity_map = self.assist._intent_mappings.get(intent_name, {}) 348 | for param in entity_map: 349 | annotations.append({param: entity_map[param]}) 350 | return annotations 351 | 352 | def create(self, user_says=True, entities=True): 353 | if user_says: 354 | self.create_user_says_skeleton() 355 | if entities: 356 | self.create_entity_skeleton() 357 | 358 | 359 | def create_user_says_skeleton(self): 360 | template = os.path.join(self.template_dir, 'user_says.yaml') 361 | 362 | skeleton = {} 363 | for intent in self.assist._intent_action_funcs: 364 | # print(type(intent)) 365 | entity_map_from_action = self.assist._intent_mappings.get(intent, {}) 366 | 367 | d = yaml.compat.ordereddict() 368 | d['UserSays'] = [None, None] 369 | d['Annotations'] = [None, None] 370 | d['Events'] = [None] 371 | 372 | # d['Annotations'] = self.parse_annotations_from_action_mappings(intent) 373 | 374 | data = yaml.comments.CommentedMap(d) # to preserve order w/o tags 375 | skeleton[intent] = data 376 | 377 | with open(template, 'a') as f: 378 | f.write('# Template for defining UserSays examples\n\n') 379 | f.write('# give-color-intent:\n\n') 380 | f.write('# UserSays:\n') 381 | f.write('# - My color is blue\n') 382 | f.write('# - red is my favorite color\n\n') 383 | f.write('# Annotations:\n') 384 | f.write('# - blue: sys.color # maps param value -> entity\n') 385 | f.write('# - red: sys.color\n\n') 386 | f.write('# Events:\n') 387 | f.write('# - event1 # adds a triggerable event named \'event1\' to the intent\n\n\n\n') 388 | # f.write(header) 389 | yaml.dump(skeleton, f, default_flow_style=False, Dumper=yaml.RoundTripDumper) 390 | 391 | 392 | def create_entity_skeleton(self): 393 | print('Creating Template for Entities') 394 | template = os.path.join(self.template_dir, 'entities.yaml') 395 | message = """# Template file for entities\n\n""" 396 | 397 | skeleton = {} 398 | for intent in self.assist._intent_action_funcs: 399 | entity_map = self.assist._intent_mappings.get(intent) 400 | action_func = self.assist._intent_action_funcs[intent][0] 401 | args = inspect.getargspec(action_func).args 402 | 403 | # dont add API 'sys' entities to the template 404 | if entity_map: 405 | args = [a for a in args if 'sys.' not in entity_map.get(a, [])] 406 | 407 | for param in [p for p in args if p not in skeleton]: 408 | skeleton[param] = [None, None] 409 | 410 | with open(template, 'w') as f: 411 | f.write(message) 412 | f.write('#Format as below\n\n') 413 | f.write("# entity_name:\n") 414 | f.write("# - entry1: list of synonyms \n") 415 | f.write("# - entry2: list of synonyms \n\n") 416 | f.write("#For example:\n\n") 417 | f.write("# drink:\n") 418 | f.write("# - water: ['aqua', 'h20'] \n") 419 | f.write("# - coffee: ['joe', 'caffeine', 'espresso', 'late'] \n") 420 | f.write("# - soda: ['pop', 'coke']\n\n\n\n") 421 | yaml.dump(skeleton, f, default_flow_style=False, Dumper=yaml.RoundTripDumper) 422 | 423 | -------------------------------------------------------------------------------- /flask_assistant/response/base.py: -------------------------------------------------------------------------------- 1 | from flask import json, make_response, current_app 2 | from flask_assistant import logger 3 | from flask_assistant.response import actions, dialogflow, hangouts, df_messenger 4 | 5 | 6 | class _Response(object): 7 | """Base webhook response to be returned to Dialogflow""" 8 | 9 | def __init__(self, speech, display_text=None, is_ssml=False): 10 | 11 | self._speech = speech 12 | self._display_text = display_text 13 | self._integrations = current_app.config.get("INTEGRATIONS", []) 14 | self._messages = [{"text": {"text": [speech]}}] 15 | self._platform_messages = {} 16 | self._render_func = None 17 | self._is_ssml = is_ssml 18 | self._response = { 19 | "fulfillmentText": speech, 20 | "fulfillmentMessages": self._messages, 21 | "payload": { 22 | "google": { # TODO: may be depreciated 23 | "expect_user_response": True, 24 | "is_ssml": True, 25 | "permissions_request": None, 26 | } 27 | }, 28 | "outputContexts": [], 29 | "source": "webhook", 30 | "followupEventInput": None, # TODO 31 | } 32 | 33 | for i in self._integrations: 34 | self._platform_messages[i] = [] 35 | 36 | if "ACTIONS_ON_GOOGLE" in self._integrations: 37 | self._set_user_storage() 38 | self._integrate_with_actions(self._speech, self._display_text, is_ssml) 39 | 40 | def add_msg(self, speech, display_text=None, is_ssml=False): 41 | self._messages.append({"text": {"text": [speech]}}) 42 | 43 | if "ACTIONS_ON_GOOGLE" in self._integrations: 44 | self._integrate_with_actions(speech, display_text, is_ssml) 45 | 46 | return self 47 | 48 | def _set_user_storage(self): 49 | from flask_assistant.core import user 50 | 51 | # If empty or unspecified, 52 | # the existing persisted token will be unchanged. 53 | user_storage = user.get("userStorage") 54 | if user_storage is None: 55 | return 56 | 57 | if isinstance(user_storage, dict): 58 | user_storage = json.dumps(user_storage) 59 | 60 | if len(user_storage.encode("utf-8")) > 10000: 61 | raise ValueError("UserStorage must not exceed 10k bytes") 62 | 63 | self._response["payload"]["google"]["userStorage"] = user_storage 64 | 65 | def _integrate_with_df_messenger(self, speech=None, display_text=None): 66 | 67 | logger.debug("Integrating with dialogflow messenger") 68 | 69 | content = {"richContent": [[]]} 70 | for m in self._platform_messages.get("DIALOGFLOW_MESSENGER", []): 71 | content["richContent"][0].append(m) 72 | 73 | payload = {"payload": content} 74 | 75 | self._messages.append(payload) 76 | 77 | def _integrate_with_hangouts(self, speech=None, display_text=None, is_ssml=False): 78 | if display_text is None: 79 | display_text = speech 80 | 81 | self._messages.append( 82 | {"platform": "GOOGLE_HANGOUTS", "text": {"text": [display_text]},} 83 | ) 84 | for m in self._platform_messages.get("GOOGLE_HANGOUTS", []): 85 | self._messages.append(m) 86 | 87 | def _integrate_with_actions(self, speech=None, display_text=None, is_ssml=False): 88 | if display_text is None: 89 | display_text = speech 90 | 91 | if is_ssml: 92 | ssml_speech = "" + speech + "" 93 | self._messages.append( 94 | { 95 | "platform": "ACTIONS_ON_GOOGLE", 96 | "simpleResponses": { 97 | "simpleResponses": [ 98 | {"ssml": ssml_speech, "displayText": display_text} 99 | ] 100 | }, 101 | } 102 | ) 103 | else: 104 | self._messages.append( 105 | { 106 | "platform": "ACTIONS_ON_GOOGLE", 107 | "simpleResponses": { 108 | "simpleResponses": [ 109 | {"textToSpeech": speech, "displayText": display_text} 110 | ] 111 | }, 112 | } 113 | ) 114 | 115 | def _include_contexts(self): 116 | from flask_assistant import core 117 | 118 | for context in core.context_manager.active: 119 | self._response["outputContexts"].append(context.serialize) 120 | 121 | def render_response(self): 122 | self._include_contexts() 123 | if self._render_func: 124 | self._render_func() 125 | 126 | self._integrate_with_df_messenger() 127 | self._integrate_with_hangouts(self._speech, self._display_text) 128 | logger.debug(json.dumps(self._response, indent=2)) 129 | resp = make_response(json.dumps(self._response)) 130 | resp.headers["Content-Type"] = "application/json" 131 | 132 | return resp 133 | 134 | def suggest(self, *replies): 135 | """Use suggestion chips to hint at responses to continue or pivot the conversation""" 136 | chips = [] 137 | for r in replies: 138 | chips.append({"title": r}) 139 | 140 | # native chips for GA 141 | self._messages.append( 142 | {"platform": "ACTIONS_ON_GOOGLE", "suggestions": {"suggestions": chips}} 143 | ) 144 | 145 | if "DIALOGFLOW_MESSENGER" in self._integrations: 146 | existing_chips = False 147 | for m in self._platform_messages["DIALOGFLOW_MESSENGER"]: 148 | # already has chips, need to add to same object 149 | if m.get("type") == "chips": 150 | existing_chips = True 151 | break 152 | 153 | if not existing_chips: 154 | chip_resp = df_messenger._build_suggestions(*replies) 155 | self._platform_messages["DIALOGFLOW_MESSENGER"].append(chip_resp) 156 | 157 | else: 158 | df_chips = [] 159 | for i in replies: 160 | chip = df_messenger._build_chip(i) 161 | df_chips.append(chip) 162 | 163 | for m in self._platform_messages["DIALOGFLOW_MESSENGER"]: 164 | if m.get("type") == "chips": 165 | m["options"].append(df_chips) 166 | 167 | return self 168 | 169 | def link_out(self, name, url): 170 | """Presents a chip similar to suggestion, but instead links to a url""" 171 | self._messages.append( 172 | { 173 | "platform": "ACTIONS_ON_GOOGLE", 174 | "linkOutSuggestion": {"destinationName": name, "uri": url}, 175 | } 176 | ) 177 | 178 | if "DIALOGFLOW_MESSENGER" in self._integrations: 179 | existing_chips = None 180 | for m in self._platform_messages["DIALOGFLOW_MESSENGER"]: 181 | # already has chips, need to add to same object 182 | if m.get("type") == "chips": 183 | existing_chips = True 184 | break 185 | 186 | link_chip = df_messenger._build_chip(name, url=url) 187 | 188 | if not existing_chips: 189 | chip_resp = {"type": "chips", "options": [link_chip]} 190 | self._platform_messages["DIALOGFLOW_MESSENGER"].append(chip_resp) 191 | 192 | else: 193 | for m in self._platform_messages["DIALOGFLOW_MESSENGER"]: 194 | if m.get("type") == "chips": 195 | m["options"].append(link_chip) 196 | 197 | return self 198 | 199 | def card( 200 | self, 201 | text, 202 | title, 203 | img_url=None, 204 | img_alt=None, 205 | subtitle=None, 206 | link=None, 207 | link_title=None, 208 | buttons=None, 209 | btn_icon=None, 210 | btn_icon_color=None, 211 | ): 212 | """Presents the user with a card response 213 | 214 | Cards may contain a title, body text, subtitle, an optional image, 215 | and a external link in the form of a button 216 | 217 | The only information required for a card are the text and title. 218 | 219 | example usage: 220 | 221 | resp = ask("Here's an example of a card") 222 | resp.card( 223 | text='The text to display', 224 | title='Card Title', 225 | img_url='http://example.com/image.png' 226 | link='https://google.com', 227 | link_title="Google it" 228 | ) 229 | 230 | return resp 231 | 232 | 233 | Arguments: 234 | text {str} -- The boody text of the card 235 | title {str} -- The card title shown in header 236 | 237 | Keyword Arguments: 238 | img_url {str} -- URL of the image to represent the item (default: {None}) 239 | img_alt {str} -- Accessibility text for the image 240 | subtitle {str} -- The subtitle displaye dbelow the title 241 | link {str} -- The https external URL to link to 242 | link_title {str} -- The text of the link button 243 | btn_icon {str} -- Icon from Material Icon library (DF_MESSENGER only) (default: chevron_right) 244 | btn_icon_color {str} -- Icon color hexcode (DF_MESSENGER only) (default: #FF9800) 245 | 246 | 247 | """ 248 | df_card = dialogflow.build_card( 249 | text, title, img_url, img_alt, subtitle, link, link_title 250 | ) 251 | self._messages.append(df_card) 252 | 253 | # df_messengar car is a combo of description + button 254 | if "DIALOGFLOW_MESSENGER" in self._integrations: 255 | 256 | if img_url is not None: 257 | description = df_messenger._build_info_response( 258 | text, title, img_url, img_alt 259 | ) 260 | 261 | else: 262 | description = df_messenger._build_description_response(text, title) 263 | 264 | self._platform_messages["DIALOGFLOW_MESSENGER"].append(description) 265 | 266 | if link: 267 | btn = df_messenger._build_button( 268 | link, link_title, btn_icon, btn_icon_color 269 | ) 270 | self._platform_messages["DIALOGFLOW_MESSENGER"].append(btn) 271 | 272 | if "GOOGLE_HANGOUTS" in self._integrations: 273 | hangouts_card = hangouts.build_card( 274 | text, title, img_url, img_alt, subtitle, link, link_title 275 | ) 276 | self._platform_messages["GOOGLE_HANGOUTS"].append(hangouts_card) 277 | 278 | if "ACTIONS_ON_GOOGLE" in self._integrations: 279 | actions_card = actions.build_card( 280 | text, title, img_url, img_alt, subtitle, link, link_title, buttons 281 | ) 282 | 283 | self._messages.append(actions_card) 284 | 285 | return self 286 | 287 | def build_list(self, title=None, items=None): 288 | """Presents the user with a vertical list of multiple items. 289 | 290 | Allows the user to select a single item. 291 | Selection generates a user query containing the title of the list item 292 | 293 | *Note* Returns a completely new object, 294 | and does not modify the existing response object 295 | Therefore, to add items, must be assigned to new variable 296 | or call the method directly after initializing list 297 | 298 | example usage: 299 | 300 | simple = ask('I speak this text') 301 | mylist = simple.build_list('List Title') 302 | mylist.add_item('Item1', 'key1') 303 | mylist.add_item('Item2', 'key2') 304 | 305 | return mylist 306 | 307 | Arguments: 308 | title {str} -- Title displayed at top of list card 309 | items {items} -- List of list items 310 | 311 | Returns: 312 | _ListSelector -- [_Response object exposing the add_item method] 313 | 314 | """ 315 | 316 | list_card = _ListSelector( 317 | self._speech, display_text=self._display_text, title=title, items=items 318 | ) 319 | return list_card 320 | 321 | def build_carousel(self, items=None): 322 | carousel = _CarouselCard( 323 | self._speech, display_text=self._display_text, items=items 324 | ) 325 | return carousel 326 | 327 | def add_media(self, url, name, description=None, icon_url=None, icon_alt=None): 328 | """Adds a Media Card Response 329 | 330 | Media responses let your Actions play audio content with a 331 | playback duration longer than the 240-second limit of SSML. 332 | 333 | Can be included with ask and tell responses. 334 | If added to an `ask` response, suggestion chips 335 | 336 | Arguments: 337 | url {str} -- Required. Url where the media is stored 338 | name {str} -- Name of media card. 339 | 340 | Optional: 341 | description {str} -- A description of the item (default: {None}) 342 | icon_url {str} -- Url of icon image 343 | icon_alt {str} -- Accessibility text for icon image 344 | 345 | example usage: 346 | 347 | resp = ask("Check out this tune") 348 | resp = resp.add_media(url, "Jazzy Tune") 349 | return resp_with_media.suggest("Next Song", "Done") 350 | 351 | 352 | """ 353 | media_object = {"contentUrl": url, "name": name} 354 | if description: 355 | media_object["description"] = description 356 | 357 | if icon_url: 358 | media_object["largeImage"] = {} 359 | media_object["largeImage"]["imageUri"] = icon_url 360 | media_object["largeImage"]["accessibilityText"] = icon_alt or name 361 | 362 | self._messages.append( 363 | { 364 | "platform": "ACTIONS_ON_GOOGLE", 365 | "mediaContent": {"mediaObjects": [media_object], "mediaType": "AUDIO",}, 366 | } 367 | ) 368 | return self 369 | 370 | 371 | def build_button(title, link): 372 | return {"title": title, "openUriAction": {"uri": link}} 373 | 374 | 375 | def build_item( 376 | title, 377 | key=None, 378 | synonyms=None, 379 | description=None, 380 | img_url=None, 381 | alt_text=None, 382 | event=None, 383 | ): 384 | """ 385 | Builds an item that may be added to List or Carousel 386 | 387 | "event" represents the Dialogflow event to be triggered on click for Dialogflow Messenger 388 | 389 | Arguments: 390 | title {str} -- Name of the item object 391 | 392 | Keyword Arguments: 393 | key {str} -- Key refering to the item. 394 | This string will be used to send a query to your app if selected 395 | synonyms {list} -- Words and phrases the user may send to select the item 396 | (default: {None}) 397 | description {str} -- A description of the item (default: {None}) 398 | img_url {str} -- URL of the image to represent the item (default: {None}) 399 | event {dict} -- Dialogflow event to be triggered on click (DF_MESSENGER only) 400 | 401 | Example: 402 | 403 | item = build_item( 404 | "My item 1", 405 | key="my_item_1", 406 | synonyms=["number one"], 407 | description="The first item in the list", 408 | event={"name": "my-select-event", parameters={"item": "my_item_1"}, languageCode: "en-US"} 409 | ) 410 | 411 | """ 412 | item = { 413 | "info": {"key": key or title, "synonyms": synonyms or []}, 414 | "title": title, 415 | "description": description, 416 | "event": event, 417 | } 418 | 419 | if img_url: 420 | img_payload = { 421 | "imageUri": img_url, 422 | "accessibilityText": alt_text or "{} img".format(title), 423 | } 424 | item["image"] = img_payload 425 | 426 | return item 427 | 428 | 429 | class _CardWithItems(_Response): 430 | """Base class for Lists and Carousels to inherit from. 431 | 432 | Provides the meth:add_item method. 433 | """ 434 | 435 | def __init__(self, speech, display_text=None, items=None): 436 | super(_CardWithItems, self).__init__(speech, display_text) 437 | self._items = items or list() 438 | self._render_func = self._add_message 439 | 440 | def _add_message(self): 441 | raise NotImplementedError 442 | 443 | def add_item( 444 | self, title, key, synonyms=None, description=None, img_url=None, event=None, 445 | ): 446 | """Adds item to a list or carousel card. 447 | 448 | A list must contain at least 2 items, each requiring a title and object key. 449 | 450 | Arguments: 451 | title {str} -- Name of the item object 452 | key {str} -- Key refering to the item. 453 | This string will be used to send a query to your app if selected 454 | 455 | Keyword Arguments: 456 | synonyms {list} -- Words and phrases the user may send to select the item 457 | (default: {None}) 458 | description {str} -- A description of the item (default: {None}) 459 | img_url {str} -- URL of the image to represent the item (default: {None}) 460 | event {dict} -- Dialogflow event to be triggered on click (DF_MESSENGER only) 461 | 462 | """ 463 | item = build_item(title, key, synonyms, description, img_url, event=event) 464 | self._items.append(item) 465 | return self 466 | 467 | def include_items(self, *item_objects): 468 | if not isinstance(item_objects, list): 469 | item_objects = list(item_objects) 470 | self._items.extend(item_objects) 471 | 472 | return self 473 | 474 | 475 | class _ListSelector(_CardWithItems): 476 | """Subclass of basic _Response to provide an instance capable of adding items.""" 477 | 478 | def __init__(self, speech, display_text=None, title=None, items=None): 479 | self._title = title 480 | 481 | super(_ListSelector, self).__init__(speech, display_text, items) 482 | 483 | def _add_message(self): 484 | 485 | self._messages.append( 486 | { 487 | "platform": "ACTIONS_ON_GOOGLE", 488 | "listSelect": {"title": self._title, "items": self._items}, 489 | } 490 | ) 491 | self._add_platform_msgs() 492 | 493 | def _add_platform_msgs(self): 494 | 495 | if "DIALOGFLOW_MESSENGER" in self._integrations: 496 | list_resp = df_messenger._build_list(self._title, self._items) 497 | self._platform_messages["DIALOGFLOW_MESSENGER"].extend(list_resp) 498 | 499 | 500 | class _CarouselCard(_ListSelector): 501 | """Subclass of _CardWithItems used to build Carousel cards.""" 502 | 503 | def __init__(self, speech, display_text=None, items=None): 504 | super(_CarouselCard, self).__init__(speech, display_text, items=items) 505 | 506 | def _add_message(self): 507 | self._messages.append( 508 | {"platform": "ACTIONS_ON_GOOGLE", "carouselSelect": {"items": self._items}} 509 | ) 510 | 511 | 512 | class tell(_Response): 513 | def __init__(self, speech, display_text=None, is_ssml=False): 514 | super(tell, self).__init__(speech, display_text, is_ssml) 515 | self._response["payload"]["google"]["expect_user_response"] = False 516 | 517 | 518 | class ask(_Response): 519 | def __init__(self, speech, display_text=None, is_ssml=False): 520 | """Returns a response to the user and keeps the current session alive. 521 | Expects a response from the user. 522 | 523 | Arguments: 524 | speech {str} -- Text to be pronounced to the user / shown on the screen 525 | """ 526 | super(ask, self).__init__(speech, display_text, is_ssml) 527 | self._response["payload"]["google"]["expect_user_response"] = True 528 | 529 | def reprompt(self, prompt): 530 | repromtKey = "text_to_speech" 531 | if self._is_ssml: 532 | repromtKey = "ssml" 533 | repromtResponse = {} 534 | repromtResponse[repromtKey] = prompt 535 | self._response["payload"]["google"]["no_input_prompts"] = [repromtResponse] 536 | return self 537 | 538 | 539 | class event(_Response): 540 | """Triggers an event to invoke it's respective intent. 541 | 542 | When an event is triggered, speech, displayText and services' data will be ignored. 543 | """ 544 | 545 | def __init__(self, event_name, **kwargs): 546 | super(event, self).__init__(speech="") 547 | 548 | self._response["followupEventInput"] = { 549 | "name": event_name, 550 | "parameters": kwargs, 551 | } 552 | 553 | 554 | class permission(_Response): 555 | """Returns a permission request to the user. 556 | 557 | Arguments: 558 | permissions {list} -- list of permissions to request for eg. ['DEVICE_PRECISE_LOCATION'] 559 | context {str} -- Text explaining the reason/value for the requested permission 560 | update_intent {str} -- name of the intent that the user wants to get updates from 561 | """ 562 | 563 | def __init__(self, permissions, context=None, update_intent=None): 564 | super(permission, self).__init__(speech=None) 565 | self._messages[:] = [] 566 | 567 | if isinstance(permissions, str): 568 | permissions = [permissions] 569 | 570 | if "UPDATE" in permissions and update_intent is None: 571 | raise ValueError("update_intent is required to ask for UPDATE permission") 572 | 573 | self._response["payload"]["google"]["systemIntent"] = { 574 | "intent": "actions.intent.PERMISSION", 575 | "data": { 576 | "@type": "type.googleapis.com/google.actions.v2.PermissionValueSpec", 577 | "optContext": context, 578 | "permissions": permissions, 579 | "updatePermissionValueSpec": {"intent": update_intent}, 580 | }, 581 | } 582 | 583 | 584 | class sign_in(_Response): 585 | """Initiates the authentication flow for Account Linking 586 | 587 | After the user authorizes the action to access their profile, a Google ID token 588 | will be received and validated by the flask-assistant and expose user profile information 589 | with the `user.profile` local 590 | 591 | In order to complete the sign in process, you will need to create an intent with 592 | the `actions_intent_SIGN_IN` event 593 | """ 594 | 595 | # Payload according to https://developers.google.com/assistant/conversational/helpers#account_sign-in 596 | def __init__(self, reason=None): 597 | super(sign_in, self).__init__(speech=None) 598 | 599 | self._messages[:] = [] 600 | self._response = { 601 | "payload": { 602 | "google": { 603 | "expectUserResponse": True, 604 | "systemIntent": { 605 | "intent": "actions.intent.SIGN_IN", 606 | "data": { 607 | "@type": "type.googleapis.com/google.actions.v2.SignInValueSpec" 608 | }, 609 | }, 610 | } 611 | } 612 | } 613 | --------------------------------------------------------------------------------