├── 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 |
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 |
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 | [](https://pypi.python.org/pypi/flask-assistant)
5 | [](https://travis-ci.org/treethought/flask-assistant)  [](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 |
--------------------------------------------------------------------------------