Add optional words to conversation utterances (#12772)

* Add optional words to conversation utterances

* Conversation to handle singular/plural

* Remove print

* Add pronounce detection to shopping list

* Lint

* fix tests

* Add optional 2 words

* Fix tests

* Conversation: coroutine -> async/await

* Replace \s with space
This commit is contained in:
Paulus Schoutsen 2018-03-01 07:35:12 -08:00 committed by GitHub
parent 7d8ca2010b
commit 491b3d707c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 111 additions and 31 deletions

View File

@ -156,9 +156,10 @@ def async_setup(hass, config):
hass.services.async_register( hass.services.async_register(
ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service)
hass.helpers.intent.async_register(intent.ServiceIntentHandler( hass.helpers.intent.async_register(intent.ServiceIntentHandler(
intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned on {}")) intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on"))
hass.helpers.intent.async_register(intent.ServiceIntentHandler( hass.helpers.intent.async_register(intent.ServiceIntentHandler(
intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, "Turned off {}")) intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF,
"Turned {} off"))
hass.helpers.intent.async_register(intent.ServiceIntentHandler( hass.helpers.intent.async_register(intent.ServiceIntentHandler(
intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}"))

View File

@ -4,7 +4,6 @@ Support for functionality to have conversations with Home Assistant.
For more details about this component, please refer to the documentation at For more details about this component, please refer to the documentation at
https://home-assistant.io/components/conversation/ https://home-assistant.io/components/conversation/
""" """
import asyncio
import logging import logging
import re import re
@ -67,8 +66,7 @@ def async_register(hass, intent_type, utterances):
conf.append(_create_matcher(utterance)) conf.append(_create_matcher(utterance))
@asyncio.coroutine async def async_setup(hass, config):
def async_setup(hass, config):
"""Register the process service.""" """Register the process service."""
config = config.get(DOMAIN, {}) config = config.get(DOMAIN, {})
intents = hass.data.get(DOMAIN) intents = hass.data.get(DOMAIN)
@ -84,49 +82,73 @@ def async_setup(hass, config):
conf.extend(_create_matcher(utterance) for utterance in utterances) conf.extend(_create_matcher(utterance) for utterance in utterances)
@asyncio.coroutine async def process(service):
def process(service):
"""Parse text into commands.""" """Parse text into commands."""
text = service.data[ATTR_TEXT] text = service.data[ATTR_TEXT]
yield from _process(hass, text) try:
await _process(hass, text)
except intent.IntentHandleError as err:
_LOGGER.error('Error processing %s: %s', text, err)
hass.services.async_register( hass.services.async_register(
DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA)
hass.http.register_view(ConversationProcessView) hass.http.register_view(ConversationProcessView)
async_register(hass, intent.INTENT_TURN_ON, # We strip trailing 's' from name because our state matcher will fail
['Turn {name} on', 'Turn on {name}']) # if a letter is not there. By removing 's' we can match singular and
async_register(hass, intent.INTENT_TURN_OFF, # plural names.
['Turn {name} off', 'Turn off {name}'])
async_register(hass, intent.INTENT_TOGGLE, async_register(hass, intent.INTENT_TURN_ON, [
['Toggle {name}', '{name} toggle']) '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',
])
return True return True
def _create_matcher(utterance): def _create_matcher(utterance):
"""Create a regex that matches the utterance.""" """Create a regex that matches the utterance."""
parts = re.split(r'({\w+})', utterance) # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL
# Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name}
parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance)
# Pattern to extract name from GROUP part. Matches {name}
group_matcher = re.compile(r'{(\w+)}') group_matcher = re.compile(r'{(\w+)}')
# Pattern to extract text from OPTIONAL part. Matches [the color]
optional_matcher = re.compile(r'\[([\w ]+)\] *')
pattern = ['^'] pattern = ['^']
for part in parts: for part in parts:
match = group_matcher.match(part) group_match = group_matcher.match(part)
optional_match = optional_matcher.match(part)
if match is None: # Normal part
if group_match is None and optional_match is None:
pattern.append(part) pattern.append(part)
continue continue
pattern.append('(?P<{}>{})'.format(match.groups()[0], r'[\w ]+')) # Group part
if group_match is not None:
pattern.append(
r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0]))
# Optional part
elif optional_match is not None:
pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0]))
pattern.append('$') pattern.append('$')
return re.compile(''.join(pattern), re.I) return re.compile(''.join(pattern), re.I)
@asyncio.coroutine async def _process(hass, text):
def _process(hass, text):
"""Process a line of text.""" """Process a line of text."""
intents = hass.data.get(DOMAIN, {}) intents = hass.data.get(DOMAIN, {})
@ -137,7 +159,7 @@ def _process(hass, text):
if not match: if not match:
continue continue
response = yield from hass.helpers.intent.async_handle( response = await hass.helpers.intent.async_handle(
DOMAIN, intent_type, DOMAIN, intent_type,
{key: {'value': value} for key, value {key: {'value': value} for key, value
in match.groupdict().items()}, text) in match.groupdict().items()}, text)
@ -153,12 +175,15 @@ class ConversationProcessView(http.HomeAssistantView):
@RequestDataValidator(vol.Schema({ @RequestDataValidator(vol.Schema({
vol.Required('text'): str, vol.Required('text'): str,
})) }))
@asyncio.coroutine async def post(self, request, data):
def post(self, request, data):
"""Send a request for processing.""" """Send a request for processing."""
hass = request.app['hass'] hass = request.app['hass']
intent_result = yield from _process(hass, data['text']) try:
intent_result = await _process(hass, data['text'])
except intent.IntentHandleError as err:
intent_result = intent.IntentResponse()
intent_result.async_set_speech(str(err))
if intent_result is None: if intent_result is None:
intent_result = intent.IntentResponse() intent_result = intent.IntentResponse()

View File

@ -43,7 +43,7 @@ def async_setup(hass, config):
hass.http.register_view(ClearCompletedItemsView) hass.http.register_view(ClearCompletedItemsView)
hass.components.conversation.async_register(INTENT_ADD_ITEM, [ hass.components.conversation.async_register(INTENT_ADD_ITEM, [
'Add {item} to my shopping list', 'Add [the] [a] [an] {item} to my shopping list',
]) ])
hass.components.conversation.async_register(INTENT_LAST_ITEMS, [ hass.components.conversation.async_register(INTENT_LAST_ITEMS, [
'What is on my shopping list' 'What is on my shopping list'

View File

@ -100,7 +100,8 @@ def async_match_state(hass, name, states=None):
state = _fuzzymatch(name, states, lambda state: state.name) state = _fuzzymatch(name, states, lambda state: state.name)
if state is None: if state is None:
raise IntentHandleError('Unable to find entity {}'.format(name)) raise IntentHandleError(
'Unable to find an entity called {}'.format(name))
return state return state

View File

@ -237,7 +237,7 @@ def test_http_api(hass, test_client):
calls = async_mock_service(hass, 'homeassistant', 'turn_on') calls = async_mock_service(hass, 'homeassistant', 'turn_on')
resp = yield from client.post('/api/conversation/process', json={ resp = yield from client.post('/api/conversation/process', json={
'text': 'Turn kitchen on' 'text': 'Turn the kitchen on'
}) })
assert resp.status == 200 assert resp.status == 200
@ -267,3 +267,56 @@ def test_http_api_wrong_data(hass, test_client):
resp = yield from client.post('/api/conversation/process', json={ resp = yield from client.post('/api/conversation/process', json={
}) })
assert resp.status == 400 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
# 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
# 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
# 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'
# 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'
# 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'

View File

@ -212,7 +212,7 @@ def test_turn_on_intent(hass):
) )
yield from hass.async_block_till_done() yield from hass.async_block_till_done()
assert response.speech['plain']['speech'] == 'Turned on test light' assert response.speech['plain']['speech'] == 'Turned test light on'
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] call = calls[0]
assert call.domain == 'light' assert call.domain == 'light'
@ -234,7 +234,7 @@ def test_turn_off_intent(hass):
) )
yield from hass.async_block_till_done() yield from hass.async_block_till_done()
assert response.speech['plain']['speech'] == 'Turned off test light' assert response.speech['plain']['speech'] == 'Turned test light off'
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] call = calls[0]
assert call.domain == 'light' assert call.domain == 'light'
@ -283,7 +283,7 @@ def test_turn_on_multiple_intent(hass):
) )
yield from hass.async_block_till_done() yield from hass.async_block_till_done()
assert response.speech['plain']['speech'] == 'Turned on test lights 2' assert response.speech['plain']['speech'] == 'Turned test lights 2 on'
assert len(calls) == 1 assert len(calls) == 1
call = calls[0] call = calls[0]
assert call.domain == 'light' assert call.domain == 'light'