From a119932ee590b9115afc97ddf56b9a95fd9f2c00 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Oct 2019 11:46:45 -0700 Subject: [PATCH] Refactor the conversation integration (#27839) * Refactor the conversation integration * Lint --- .../components/conversation/__init__.py | 133 ++++-------------- .../components/conversation/agent.py | 12 ++ .../components/conversation/const.py | 3 + .../components/conversation/default_agent.py | 127 +++++++++++++++++ homeassistant/components/hangouts/__init__.py | 3 +- .../components/shopping_list/__init__.py | 7 - tests/components/conversation/test_init.py | 65 +++------ tests/components/conversation/test_util.py | 55 ++++++++ 8 files changed, 242 insertions(+), 163 deletions(-) create mode 100644 homeassistant/components/conversation/agent.py create mode 100644 homeassistant/components/conversation/const.py create mode 100644 homeassistant/components/conversation/default_agent.py create mode 100644 tests/components/conversation/test_util.py diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 9d7d510b10e..798fc926e0f 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -6,15 +6,12 @@ import voluptuous as vol from homeassistant import core from homeassistant.components import http -from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import EVENT_COMPONENT_LOADED -from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, intent from homeassistant.loader import bind_hass -from homeassistant.setup import ATTR_COMPONENT -from .util import create_matcher +from .agent import AbstractConversationAgent +from .default_agent import async_register, DefaultAgent _LOGGER = logging.getLogger(__name__) @@ -22,15 +19,8 @@ ATTR_TEXT = "text" DOMAIN = "conversation" -REGEX_TURN_COMMAND = re.compile(r"turn (?P(?: |\w)+) (?P\w+)") REGEX_TYPE = type(re.compile("")) - -UTTERANCES = { - "cover": { - INTENT_OPEN_COVER: ["Open [the] [a] [an] {name}[s]"], - INTENT_CLOSE_COVER: ["Close [the] [a] [an] {name}[s]"], - } -} +DATA_AGENT = "conversation_agent" SERVICE_PROCESS = "process" @@ -50,137 +40,64 @@ CONFIG_SCHEMA = vol.Schema( ) +async_register = bind_hass(async_register) # pylint: disable=invalid-name + + @core.callback @bind_hass -def async_register(hass, intent_type, utterances): - """Register utterances and any custom intents. - - Registrations don't require conversations to be loaded. They will become - active once the conversation component is loaded. - """ - intents = hass.data.get(DOMAIN) - - if intents is None: - intents = hass.data[DOMAIN] = {} - - conf = intents.get(intent_type) - - if conf is None: - conf = intents[intent_type] = [] - - for utterance in utterances: - if isinstance(utterance, REGEX_TYPE): - conf.append(utterance) - else: - conf.append(create_matcher(utterance)) +def async_set_agent(hass: core.HomeAssistant, agent: AbstractConversationAgent): + """Set the agent to handle the conversations.""" + hass.data[DATA_AGENT] = agent async def async_setup(hass, config): """Register the process service.""" - config = config.get(DOMAIN, {}) - intents = hass.data.get(DOMAIN) - if intents is None: - intents = hass.data[DOMAIN] = {} + async def process(hass, text): + """Process a line of text.""" + agent = hass.data.get(DATA_AGENT) - for intent_type, utterances in config.get("intents", {}).items(): - conf = intents.get(intent_type) + if agent is None: + agent = hass.data[DATA_AGENT] = DefaultAgent(hass) + await agent.async_initialize(config) - if conf is None: - conf = intents[intent_type] = [] + return await agent.async_process(text) - conf.extend(create_matcher(utterance) for utterance in utterances) - - async def process(service): + async def handle_service(service): """Parse text into commands.""" text = service.data[ATTR_TEXT] _LOGGER.debug("Processing: <%s>", text) try: - await _process(hass, text) + await process(hass, text) except intent.IntentHandleError as err: _LOGGER.error("Error processing %s: %s", text, err) hass.services.async_register( - DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA + DOMAIN, SERVICE_PROCESS, handle_service, schema=SERVICE_PROCESS_SCHEMA ) - hass.http.register_view(ConversationProcessView) - - # We strip trailing 's' from name because our state matcher will fail - # if a letter is not there. By removing 's' we can match singular and - # plural names. - - async_register( - hass, - intent.INTENT_TURN_ON, - ["Turn [the] [a] {name}[s] on", "Turn on [the] [a] [an] {name}[s]"], - ) - async_register( - hass, - intent.INTENT_TURN_OFF, - ["Turn [the] [a] [an] {name}[s] off", "Turn off [the] [a] [an] {name}[s]"], - ) - async_register( - hass, - intent.INTENT_TOGGLE, - ["Toggle [the] [a] [an] {name}[s]", "[the] [a] [an] {name}[s] toggle"], - ) - - @callback - def register_utterances(component): - """Register utterances for a component.""" - if component not in UTTERANCES: - return - for intent_type, sentences in UTTERANCES[component].items(): - async_register(hass, intent_type, sentences) - - @callback - def component_loaded(event): - """Handle a new component loaded.""" - register_utterances(event.data[ATTR_COMPONENT]) - - hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) - - # Check already loaded components. - for component in hass.config.components: - register_utterances(component) + hass.http.register_view(ConversationProcessView(process)) return True -async def _process(hass, text): - """Process a line of text.""" - intents = hass.data.get(DOMAIN, {}) - - for intent_type, matchers in intents.items(): - for matcher in matchers: - match = matcher.match(text) - - if not match: - continue - - response = await hass.helpers.intent.async_handle( - DOMAIN, - intent_type, - {key: {"value": value} for key, value in match.groupdict().items()}, - text, - ) - return response - - class ConversationProcessView(http.HomeAssistantView): """View to retrieve shopping list content.""" url = "/api/conversation/process" name = "api:conversation:process" + def __init__(self, process): + """Initialize the conversation process view.""" + self._process = process + @RequestDataValidator(vol.Schema({vol.Required("text"): str})) async def post(self, request, data): """Send a request for processing.""" hass = request.app["hass"] try: - intent_result = await _process(hass, data["text"]) + intent_result = await self._process(hass, data["text"]) except intent.IntentHandleError as err: intent_result = intent.IntentResponse() intent_result.async_set_speech(str(err)) diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py new file mode 100644 index 00000000000..eae6402530c --- /dev/null +++ b/homeassistant/components/conversation/agent.py @@ -0,0 +1,12 @@ +"""Agent foundation for conversation integration.""" +from abc import ABC, abstractmethod + +from homeassistant.helpers import intent + + +class AbstractConversationAgent(ABC): + """Abstract conversation agent.""" + + @abstractmethod + async def async_process(self, text: str) -> intent.IntentResponse: + """Process a sentence.""" diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py new file mode 100644 index 00000000000..04bfa373061 --- /dev/null +++ b/homeassistant/components/conversation/const.py @@ -0,0 +1,3 @@ +"""Const for conversation integration.""" + +DOMAIN = "conversation" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py new file mode 100644 index 00000000000..e93afcfaf65 --- /dev/null +++ b/homeassistant/components/conversation/default_agent.py @@ -0,0 +1,127 @@ +"""Standard conversastion implementation for Home Assistant.""" +import logging +import re + +from homeassistant import core +from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER +from homeassistant.components.shopping_list import INTENT_ADD_ITEM, INTENT_LAST_ITEMS +from homeassistant.const import EVENT_COMPONENT_LOADED +from homeassistant.core import callback +from homeassistant.helpers import intent +from homeassistant.setup import ATTR_COMPONENT + +from .agent import AbstractConversationAgent +from .const import DOMAIN +from .util import create_matcher + +_LOGGER = logging.getLogger(__name__) + +REGEX_TURN_COMMAND = re.compile(r"turn (?P(?: |\w)+) (?P\w+)") +REGEX_TYPE = type(re.compile("")) + +UTTERANCES = { + "cover": { + INTENT_OPEN_COVER: ["Open [the] [a] [an] {name}[s]"], + INTENT_CLOSE_COVER: ["Close [the] [a] [an] {name}[s]"], + }, + "shopping_list": { + INTENT_ADD_ITEM: ["Add [the] [a] [an] {item} to my shopping list"], + INTENT_LAST_ITEMS: ["What is on my shopping list"], + }, +} + + +@core.callback +def async_register(hass, intent_type, utterances): + """Register utterances and any custom intents for the default agent. + + Registrations don't require conversations to be loaded. They will become + active once the conversation component is loaded. + """ + intents = hass.data.setdefault(DOMAIN, {}) + conf = intents.setdefault(intent_type, []) + + for utterance in utterances: + if isinstance(utterance, REGEX_TYPE): + conf.append(utterance) + else: + conf.append(create_matcher(utterance)) + + +class DefaultAgent(AbstractConversationAgent): + """Default agent for conversation agent.""" + + def __init__(self, hass: core.HomeAssistant): + """Initialize the default agent.""" + self.hass = hass + + async def async_initialize(self, config): + """Initialize the default agent.""" + config = config.get(DOMAIN, {}) + intents = self.hass.data.setdefault(DOMAIN, {}) + + for intent_type, utterances in config.get("intents", {}).items(): + conf = intents.get(intent_type) + + if conf is None: + conf = intents[intent_type] = [] + + conf.extend(create_matcher(utterance) for utterance in utterances) + + # We strip trailing 's' from name because our state matcher will fail + # if a letter is not there. By removing 's' we can match singular and + # plural names. + + async_register( + self.hass, + intent.INTENT_TURN_ON, + ["Turn [the] [a] {name}[s] on", "Turn on [the] [a] [an] {name}[s]"], + ) + async_register( + self.hass, + intent.INTENT_TURN_OFF, + ["Turn [the] [a] [an] {name}[s] off", "Turn off [the] [a] [an] {name}[s]"], + ) + async_register( + self.hass, + intent.INTENT_TOGGLE, + ["Toggle [the] [a] [an] {name}[s]", "[the] [a] [an] {name}[s] toggle"], + ) + + @callback + def component_loaded(event): + """Handle a new component loaded.""" + self.register_utterances(event.data[ATTR_COMPONENT]) + + self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) + + # Check already loaded components. + for component in self.hass.config.components: + self.register_utterances(component) + + @callback + def register_utterances(self, component): + """Register utterances for a component.""" + if component not in UTTERANCES: + return + for intent_type, sentences in UTTERANCES[component].items(): + async_register(self.hass, intent_type, sentences) + + async def async_process(self, text) -> intent.IntentResponse: + """Process a sentence.""" + intents = self.hass.data[DOMAIN] + + for intent_type, matchers in intents.items(): + for matcher in matchers: + match = matcher.match(text) + + if not match: + continue + + return await intent.async_handle( + self.hass, + DOMAIN, + intent_type, + {key: {"value": value} for key, value in match.groupdict().items()}, + text, + ) diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index 885ac5d1670..953994d6ac0 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -7,6 +7,7 @@ from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import dispatcher, intent import homeassistant.helpers.config_validation as cv +from homeassistant.components.conversation.util import create_matcher # We need an import from .config_flow, without it .config_flow is never loaded. from .intents import HelpIntent @@ -54,8 +55,6 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): """Set up the Hangouts bot component.""" - from homeassistant.components.conversation import create_matcher - config = config.get(DOMAIN) if config is None: hass.data[DOMAIN] = { diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 3c9cb4391a7..a5e901b8c6e 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -101,13 +101,6 @@ def async_setup(hass, config): hass.http.register_view(UpdateShoppingListItemView) hass.http.register_view(ClearCompletedItemsView) - hass.components.conversation.async_register( - INTENT_ADD_ITEM, ["Add [the] [a] [an] {item} to my shopping list"] - ) - hass.components.conversation.async_register( - INTENT_LAST_ITEMS, ["What is on my shopping list"] - ) - hass.components.frontend.async_register_built_in_panel( "shopping-list", "shopping_list", "mdi:cart" ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index d4142f2ce5f..a9116ac0d98 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -263,54 +263,27 @@ async def test_http_api_wrong_data(hass, hass_client): assert resp.status == 400 -def test_create_matcher(): - """Test the create matcher method.""" - # Basic sentence - pattern = conversation.create_matcher("Hello world") - assert pattern.match("Hello world") is not None +async def test_custom_agent(hass, hass_client): + """Test a custom conversation agent.""" - # Match a part - pattern = conversation.create_matcher("Hello {name}") - match = pattern.match("hello world") - assert match is not None - assert match.groupdict()["name"] == "world" - no_match = pattern.match("Hello world, how are you?") - assert no_match is None + class MyAgent(conversation.AbstractConversationAgent): + """Test Agent.""" - # Optional and matching part - pattern = conversation.create_matcher("Turn on [the] {name}") - match = pattern.match("turn on the kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn off kitchen lights") - assert match is None + async def async_process(self, text): + """Process some text.""" + response = intent.IntentResponse() + response.async_set_speech("Test response") + return response - # Two different optional parts, 1 matching part - pattern = conversation.create_matcher("Turn on [the] [a] {name}") - match = pattern.match("turn on the kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on a kitchen light") - assert match is not None - assert match.groupdict()["name"] == "kitchen light" + conversation.async_set_agent(hass, MyAgent()) - # Strip plural - pattern = conversation.create_matcher("Turn {name}[s] on") - match = pattern.match("turn kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen light" + assert await async_setup_component(hass, "conversation", {}) - # Optional 2 words - pattern = conversation.create_matcher("Turn [the great] {name} on") - match = pattern.match("turn the great kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" + client = await hass_client() + + resp = await client.post("/api/conversation/process", json={"text": "Test Text"}) + assert resp.status == 200 + assert await resp.json() == { + "card": {}, + "speech": {"plain": {"extra_data": None, "speech": "Test response"}}, + } diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py new file mode 100644 index 00000000000..2fa4527e9b1 --- /dev/null +++ b/tests/components/conversation/test_util.py @@ -0,0 +1,55 @@ +"""Test the conversation utils.""" +from homeassistant.components.conversation.util import create_matcher + + +def test_create_matcher(): + """Test the create matcher method.""" + # Basic sentence + pattern = create_matcher("Hello world") + assert pattern.match("Hello world") is not None + + # Match a part + pattern = create_matcher("Hello {name}") + match = pattern.match("hello world") + assert match is not None + assert match.groupdict()["name"] == "world" + no_match = pattern.match("Hello world, how are you?") + assert no_match is None + + # Optional and matching part + pattern = create_matcher("Turn on [the] {name}") + match = pattern.match("turn on the kitchen lights") + assert match is not None + assert match.groupdict()["name"] == "kitchen lights" + match = pattern.match("turn on kitchen lights") + assert match is not None + assert match.groupdict()["name"] == "kitchen lights" + match = pattern.match("turn off kitchen lights") + assert match is None + + # Two different optional parts, 1 matching part + pattern = create_matcher("Turn on [the] [a] {name}") + match = pattern.match("turn on the kitchen lights") + assert match is not None + assert match.groupdict()["name"] == "kitchen lights" + match = pattern.match("turn on kitchen lights") + assert match is not None + assert match.groupdict()["name"] == "kitchen lights" + match = pattern.match("turn on a kitchen light") + assert match is not None + assert match.groupdict()["name"] == "kitchen light" + + # Strip plural + pattern = create_matcher("Turn {name}[s] on") + match = pattern.match("turn kitchen lights on") + assert match is not None + assert match.groupdict()["name"] == "kitchen light" + + # Optional 2 words + pattern = create_matcher("Turn [the great] {name} on") + match = pattern.match("turn the great kitchen lights on") + assert match is not None + assert match.groupdict()["name"] == "kitchen lights" + match = pattern.match("turn kitchen lights on") + assert match is not None + assert match.groupdict()["name"] == "kitchen lights"