From 14f8bc26d1fd026fd5ba6babddac9e00f81a5d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20L=C3=B3pez?= Date: Tue, 31 Jan 2017 16:54:54 +0100 Subject: [PATCH] Voice command API.AI. First import (#5462) * Voice command API.AI. First import * Fixes suggested by hound * Fixing comments * Fix pylint and pydocstyle errors * Change how speech is defined Also clean some unused constants, remove card type (not used), define a message when action is not defined and improve the message when action is unknown. * Change how speech is defined Clean some constants. Improve error messages. Delete card type, not used. * Tests for new Api.ai component * Use async_add_job to python compatibility. New test to measure response time * Add async_action option to choose between waiting or not for the action to execute * Travis-ci needs more time * Removed timeout tests * Removed timeout tests * Added apiai to .coveragerc as specified by PR doc --- .coveragerc | 1 + homeassistant/components/apiai.py | 172 ++++++++++ tests/components/test_apiai.py | 513 ++++++++++++++++++++++++++++++ 3 files changed, 686 insertions(+) create mode 100644 homeassistant/components/apiai.py create mode 100644 tests/components/test_apiai.py diff --git a/.coveragerc b/.coveragerc index 26c19d49cd3..1662f10b175 100644 --- a/.coveragerc +++ b/.coveragerc @@ -129,6 +129,7 @@ omit = homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/nx584.py homeassistant/components/alarm_control_panel/simplisafe.py + homeassistant/components/apiai.py homeassistant/components/binary_sensor/arest.py homeassistant/components/binary_sensor/concord232.py homeassistant/components/binary_sensor/flic.py diff --git a/homeassistant/components/apiai.py b/homeassistant/components/apiai.py new file mode 100644 index 00000000000..769283fa5d9 --- /dev/null +++ b/homeassistant/components/apiai.py @@ -0,0 +1,172 @@ +""" +Support for API.AI webhook. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/apiai/ +""" +import asyncio +import copy +import logging + +import voluptuous as vol + +from homeassistant.const import PROJECT_NAME, HTTP_BAD_REQUEST +from homeassistant.helpers import template, script, config_validation as cv +from homeassistant.components.http import HomeAssistantView + +_LOGGER = logging.getLogger(__name__) + +INTENTS_API_ENDPOINT = '/api/apiai' + +CONF_INTENTS = 'intents' +CONF_SPEECH = 'speech' +CONF_ACTION = 'action' +CONF_ASYNC_ACTION = 'async_action' + +DEFAULT_CONF_ASYNC_ACTION = False + +DOMAIN = 'apiai' +DEPENDENCIES = ['http'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_INTENTS: { + cv.string: { + vol.Optional(CONF_SPEECH): cv.template, + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ASYNC_ACTION, + default=DEFAULT_CONF_ASYNC_ACTION): cv.boolean + } + } + } +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Activate API.AI component.""" + intents = config[DOMAIN].get(CONF_INTENTS, {}) + + hass.http.register_view(ApiaiIntentsView(hass, intents)) + + return True + + +class ApiaiIntentsView(HomeAssistantView): + """Handle API.AI requests.""" + + url = INTENTS_API_ENDPOINT + name = 'api:apiai' + + def __init__(self, hass, intents): + """Initialize API.AI view.""" + super().__init__() + + self.hass = hass + intents = copy.deepcopy(intents) + template.attach(hass, intents) + + for name, intent in intents.items(): + if CONF_ACTION in intent: + intent[CONF_ACTION] = script.Script( + hass, intent[CONF_ACTION], "Apiai intent {}".format(name)) + + self.intents = intents + + @asyncio.coroutine + def post(self, request): + """Handle API.AI.""" + data = yield from request.json() + + _LOGGER.debug('Received Apiai request: %s', data) + + req = data.get('result') + + if req is None: + _LOGGER.error('Received invalid data from Apiai: %s', data) + return self.json_message('Expected result value not received', + HTTP_BAD_REQUEST) + + action_incomplete = req['actionIncomplete'] + + if action_incomplete: + return None + + # use intent to no mix HASS actions with this parameter + intent = req.get('action') + parameters = req.get('parameters') + # contexts = req.get('contexts') + response = ApiaiResponse(parameters) + + # Default Welcome Intent + # Maybe is better to handle this in api.ai directly? + # + # if intent == 'input.welcome': + # response.add_speech( + # "Hello, and welcome to the future. How may I help?") + # return self.json(response) + + if intent == "": + _LOGGER.warning('Received intent with empty action') + response.add_speech( + "You have not defined an action in your api.ai intent.") + return self.json(response) + + config = self.intents.get(intent) + + if config is None: + _LOGGER.warning('Received unknown intent %s', intent) + response.add_speech( + "Intent '%s' is not yet configured within Home Assistant." % + intent) + return self.json(response) + + speech = config.get(CONF_SPEECH) + action = config.get(CONF_ACTION) + async_action = config.get(CONF_ASYNC_ACTION) + + if action is not None: + # API.AI expects a response in less than 5s + if async_action: + # Do not wait for the action to be executed. + # Needed if the action will take longer than 5s to execute + self.hass.async_add_job(action.async_run(response.parameters)) + else: + # Wait for the action to be executed so we can use results to + # render the answer + yield from action.async_run(response.parameters) + + # pylint: disable=unsubscriptable-object + if speech is not None: + response.add_speech(speech) + + return self.json(response) + + +class ApiaiResponse(object): + """Help generating the response for API.AI.""" + + def __init__(self, parameters): + """Initialize the response.""" + self.speech = None + self.parameters = {} + # Parameter names replace '.' and '-' for '_' + for key, value in parameters.items(): + underscored_key = key.replace('.', '_').replace('-', '_') + self.parameters[underscored_key] = value + + def add_speech(self, text): + """Add speech to the response.""" + assert self.speech is None + + if isinstance(text, template.Template): + text = text.async_render(self.parameters) + + self.speech = text + + def as_dict(self): + """Return response in an API.AI valid dict.""" + return { + 'speech': self.speech, + 'displayText': self.speech, + 'source': PROJECT_NAME, + } diff --git a/tests/components/test_apiai.py b/tests/components/test_apiai.py new file mode 100644 index 00000000000..9023ee161c5 --- /dev/null +++ b/tests/components/test_apiai.py @@ -0,0 +1,513 @@ +"""The tests for the APIAI component.""" +# pylint: disable=protected-access +import json +import unittest + +import requests + +from homeassistant.core import callback +from homeassistant import bootstrap, const +from homeassistant.components import apiai, http + +from tests.common import get_test_instance_port, get_test_home_assistant + +API_PASSWORD = "test1234" +SERVER_PORT = get_test_instance_port() +BASE_API_URL = "http://127.0.0.1:{}".format(SERVER_PORT) +INTENTS_API_URL = "{}{}".format(BASE_API_URL, apiai.INTENTS_API_ENDPOINT) + +HA_HEADERS = { + const.HTTP_HEADER_HA_AUTH: API_PASSWORD, + const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON, +} + +SESSION_ID = "a9b84cec-46b6-484e-8f31-f65dba03ae6d" +INTENT_ID = "c6a74079-a8f0-46cd-b372-5a934d23591c" +INTENT_NAME = "tests" +REQUEST_ID = "19ef7e78-fe15-4e94-99dd-0c0b1e8753c3" +REQUEST_TIMESTAMP = "2017-01-21T17:54:18.952Z" +CONTEXT_NAME = "78a5db95-b7d6-4d50-9c9b-2fc73a5e34c3_id_dialog_context" +MAX_RESPONSE_TIME = 5 # https://docs.api.ai/docs/webhook + +# An unknown action takes 8s to return. Request timeout should be bigger to +# allow the test to finish +REQUEST_TIMEOUT = 15 + +# pylint: disable=invalid-name +hass = None +calls = [] + + +# pylint: disable=invalid-name +def setUpModule(): + """Initialize a Home Assistant server for testing this module.""" + global hass + + hass = get_test_home_assistant() + + bootstrap.setup_component( + hass, http.DOMAIN, + {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, + http.CONF_SERVER_PORT: SERVER_PORT}}) + + @callback + def mock_service(call): + """Mock action call.""" + calls.append(call) + + hass.services.register("test", "apiai", mock_service) + + bootstrap.setup_component(hass, apiai.DOMAIN, { + # Key is here to verify we allow other keys in config too + "homeassistant": {}, + "apiai": { + "intents": { + "WhereAreWeIntent": { + "speech": + """ + {%- if is_state("device_tracker.paulus", "home") + and is_state("device_tracker.anne_therese", + "home") -%} + You are both home, you silly + {%- else -%} + Anne Therese is at {{ + states("device_tracker.anne_therese") + }} and Paulus is at {{ + states("device_tracker.paulus") + }} + {% endif %} + """, + }, + "GetZodiacHoroscopeIntent": { + "speech": "You told us your sign is {{ ZodiacSign }}.", + }, + "CallServiceIntent": { + "speech": "Service called", + "action": { + "service": "test.apiai", + "data_template": { + "hello": "{{ ZodiacSign }}" + }, + "entity_id": "switch.test", + } + } + } + } + }) + + hass.start() + + +# pylint: disable=invalid-name +def tearDownModule(): + """Stop the Home Assistant server.""" + hass.stop() + + +def _intent_req(data): + return requests.post(INTENTS_API_URL, data=json.dumps(data), + timeout=REQUEST_TIMEOUT, headers=HA_HEADERS) + + +class TestApiai(unittest.TestCase): + """Test APIAI.""" + + def tearDown(self): + """Stop everything that was started.""" + hass.block_till_done() + + def test_intent_action_incomplete(self): + """Test when action is not completed.""" + data = { + "id": REQUEST_ID, + "timestamp": REQUEST_TIMESTAMP, + "result": { + "source": "agent", + "resolvedQuery": "my zodiac sign is virgo", + "speech": "", + "action": "GetZodiacHoroscopeIntent", + "actionIncomplete": True, + "parameters": { + "ZodiacSign": "virgo" + }, + "metadata": { + "intentId": INTENT_ID, + "webhookUsed": "true", + "webhookForSlotFillingUsed": "false", + "intentName": INTENT_NAME + }, + "fulfillment": { + "speech": "", + "messages": [ + { + "type": 0, + "speech": "" + } + ] + }, + "score": 1 + }, + "status": { + "code": 200, + "errorType": "success" + }, + "sessionId": SESSION_ID, + "originalRequest": None + } + + req = _intent_req(data) + self.assertEqual(200, req.status_code) + self.assertEqual("", req.text) + + def test_intent_slot_filling(self): + """Test when API.AI asks for slot-filling return none.""" + data = { + "id": REQUEST_ID, + "timestamp": REQUEST_TIMESTAMP, + "result": { + "source": "agent", + "resolvedQuery": "my zodiac sign is", + "speech": "", + "action": "GetZodiacHoroscopeIntent", + "actionIncomplete": True, + "parameters": { + "ZodiacSign": "" + }, + "contexts": [ + { + "name": CONTEXT_NAME, + "parameters": { + "ZodiacSign.original": "", + "ZodiacSign": "" + }, + "lifespan": 2 + }, + { + "name": "tests_ha_dialog_context", + "parameters": { + "ZodiacSign.original": "", + "ZodiacSign": "" + }, + "lifespan": 2 + }, + { + "name": "tests_ha_dialog_params_zodiacsign", + "parameters": { + "ZodiacSign.original": "", + "ZodiacSign": "" + }, + "lifespan": 1 + } + ], + "metadata": { + "intentId": INTENT_ID, + "webhookUsed": "true", + "webhookForSlotFillingUsed": "true", + "intentName": INTENT_NAME + }, + "fulfillment": { + "speech": "What is the ZodiacSign?", + "messages": [ + { + "type": 0, + "speech": "What is the ZodiacSign?" + } + ] + }, + "score": 0.77 + }, + "status": { + "code": 200, + "errorType": "success" + }, + "sessionId": SESSION_ID, + "originalRequest": None + } + + req = _intent_req(data) + self.assertEqual(200, req.status_code) + self.assertEqual("", req.text) + + def test_intent_request_with_parameters(self): + """Test a request with parameters.""" + data = { + "id": REQUEST_ID, + "timestamp": REQUEST_TIMESTAMP, + "result": { + "source": "agent", + "resolvedQuery": "my zodiac sign is virgo", + "speech": "", + "action": "GetZodiacHoroscopeIntent", + "actionIncomplete": False, + "parameters": { + "ZodiacSign": "virgo" + }, + "contexts": [], + "metadata": { + "intentId": INTENT_ID, + "webhookUsed": "true", + "webhookForSlotFillingUsed": "false", + "intentName": INTENT_NAME + }, + "fulfillment": { + "speech": "", + "messages": [ + { + "type": 0, + "speech": "" + } + ] + }, + "score": 1 + }, + "status": { + "code": 200, + "errorType": "success" + }, + "sessionId": SESSION_ID, + "originalRequest": None + } + req = _intent_req(data) + self.assertEqual(200, req.status_code) + text = req.json().get("speech") + self.assertEqual("You told us your sign is virgo.", text) + + def test_intent_request_with_parameters_but_empty(self): + """Test a request with parameters but empty value.""" + data = { + "id": REQUEST_ID, + "timestamp": REQUEST_TIMESTAMP, + "result": { + "source": "agent", + "resolvedQuery": "my zodiac sign is virgo", + "speech": "", + "action": "GetZodiacHoroscopeIntent", + "actionIncomplete": False, + "parameters": { + "ZodiacSign": "" + }, + "contexts": [], + "metadata": { + "intentId": INTENT_ID, + "webhookUsed": "true", + "webhookForSlotFillingUsed": "false", + "intentName": INTENT_NAME + }, + "fulfillment": { + "speech": "", + "messages": [ + { + "type": 0, + "speech": "" + } + ] + }, + "score": 1 + }, + "status": { + "code": 200, + "errorType": "success" + }, + "sessionId": SESSION_ID, + "originalRequest": None + } + req = _intent_req(data) + self.assertEqual(200, req.status_code) + text = req.json().get("speech") + self.assertEqual("You told us your sign is .", text) + + def test_intent_request_without_slots(self): + """Test a request without slots.""" + data = { + "id": REQUEST_ID, + "timestamp": REQUEST_TIMESTAMP, + "result": { + "source": "agent", + "resolvedQuery": "where are we", + "speech": "", + "action": "WhereAreWeIntent", + "actionIncomplete": False, + "parameters": {}, + "contexts": [], + "metadata": { + "intentId": INTENT_ID, + "webhookUsed": "true", + "webhookForSlotFillingUsed": "false", + "intentName": INTENT_NAME + }, + "fulfillment": { + "speech": "", + "messages": [ + { + "type": 0, + "speech": "" + } + ] + }, + "score": 1 + }, + "status": { + "code": 200, + "errorType": "success" + }, + "sessionId": SESSION_ID, + "originalRequest": None + } + req = _intent_req(data) + self.assertEqual(200, req.status_code) + text = req.json().get("speech") + + self.assertEqual("Anne Therese is at unknown and Paulus is at unknown", + text) + + hass.states.set("device_tracker.paulus", "home") + hass.states.set("device_tracker.anne_therese", "home") + + req = _intent_req(data) + self.assertEqual(200, req.status_code) + text = req.json().get("speech") + self.assertEqual("You are both home, you silly", text) + + def test_intent_request_calling_service(self): + """Test a request for calling a service. + + If this request is done async the test could finish before the action + has been executed. Hard to test because it will be a race condition. + """ + data = { + "id": REQUEST_ID, + "timestamp": REQUEST_TIMESTAMP, + "result": { + "source": "agent", + "resolvedQuery": "my zodiac sign is virgo", + "speech": "", + "action": "CallServiceIntent", + "actionIncomplete": False, + "parameters": { + "ZodiacSign": "virgo" + }, + "contexts": [], + "metadata": { + "intentId": INTENT_ID, + "webhookUsed": "true", + "webhookForSlotFillingUsed": "false", + "intentName": INTENT_NAME + }, + "fulfillment": { + "speech": "", + "messages": [ + { + "type": 0, + "speech": "" + } + ] + }, + "score": 1 + }, + "status": { + "code": 200, + "errorType": "success" + }, + "sessionId": SESSION_ID, + "originalRequest": None + } + call_count = len(calls) + req = _intent_req(data) + self.assertEqual(200, req.status_code) + self.assertEqual(call_count + 1, len(calls)) + call = calls[-1] + self.assertEqual("test", call.domain) + self.assertEqual("apiai", call.service) + self.assertEqual(["switch.test"], call.data.get("entity_id")) + self.assertEqual("virgo", call.data.get("hello")) + + def test_intent_with_no_action(self): + """Test a intent with no defined action.""" + data = { + "id": REQUEST_ID, + "timestamp": REQUEST_TIMESTAMP, + "result": { + "source": "agent", + "resolvedQuery": "my zodiac sign is virgo", + "speech": "", + "action": "", + "actionIncomplete": False, + "parameters": { + "ZodiacSign": "" + }, + "contexts": [], + "metadata": { + "intentId": INTENT_ID, + "webhookUsed": "true", + "webhookForSlotFillingUsed": "false", + "intentName": INTENT_NAME + }, + "fulfillment": { + "speech": "", + "messages": [ + { + "type": 0, + "speech": "" + } + ] + }, + "score": 1 + }, + "status": { + "code": 200, + "errorType": "success" + }, + "sessionId": SESSION_ID, + "originalRequest": None + } + req = _intent_req(data) + self.assertEqual(200, req.status_code) + text = req.json().get("speech") + self.assertEqual( + "You have not defined an action in your api.ai intent.", text) + + def test_intent_with_unknown_action(self): + """Test a intent with an action not defined in the conf.""" + data = { + "id": REQUEST_ID, + "timestamp": REQUEST_TIMESTAMP, + "result": { + "source": "agent", + "resolvedQuery": "my zodiac sign is virgo", + "speech": "", + "action": "unknown", + "actionIncomplete": False, + "parameters": { + "ZodiacSign": "" + }, + "contexts": [], + "metadata": { + "intentId": INTENT_ID, + "webhookUsed": "true", + "webhookForSlotFillingUsed": "false", + "intentName": INTENT_NAME + }, + "fulfillment": { + "speech": "", + "messages": [ + { + "type": 0, + "speech": "" + } + ] + }, + "score": 1 + }, + "status": { + "code": 200, + "errorType": "success" + }, + "sessionId": SESSION_ID, + "originalRequest": None + } + req = _intent_req(data) + self.assertEqual(200, req.status_code) + text = req.json().get("speech") + self.assertEqual( + "Intent 'unknown' is not yet configured within Home Assistant.", + text)