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
pull/12817/head
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(
ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service)
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(
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(
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
https://home-assistant.io/components/conversation/
"""
import asyncio
import logging
import re
@ -67,8 +66,7 @@ def async_register(hass, intent_type, utterances):
conf.append(_create_matcher(utterance))
@asyncio.coroutine
def async_setup(hass, config):
async def async_setup(hass, config):
"""Register the process service."""
config = config.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)
@asyncio.coroutine
def process(service):
async def process(service):
"""Parse text into commands."""
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(
DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA)
hass.http.register_view(ConversationProcessView)
async_register(hass, intent.INTENT_TURN_ON,
['Turn {name} on', 'Turn on {name}'])
async_register(hass, intent.INTENT_TURN_OFF,
['Turn {name} off', 'Turn off {name}'])
async_register(hass, intent.INTENT_TOGGLE,
['Toggle {name}', '{name} toggle'])
# 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',
])
return True
def _create_matcher(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+)}')
# Pattern to extract text from OPTIONAL part. Matches [the color]
optional_matcher = re.compile(r'\[([\w ]+)\] *')
pattern = ['^']
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)
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('$')
return re.compile(''.join(pattern), re.I)
@asyncio.coroutine
def _process(hass, text):
async def _process(hass, text):
"""Process a line of text."""
intents = hass.data.get(DOMAIN, {})
@ -137,7 +159,7 @@ def _process(hass, text):
if not match:
continue
response = yield from hass.helpers.intent.async_handle(
response = await hass.helpers.intent.async_handle(
DOMAIN, intent_type,
{key: {'value': value} for key, value
in match.groupdict().items()}, text)
@ -153,12 +175,15 @@ class ConversationProcessView(http.HomeAssistantView):
@RequestDataValidator(vol.Schema({
vol.Required('text'): str,
}))
@asyncio.coroutine
def post(self, request, data):
async def post(self, request, data):
"""Send a request for processing."""
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:
intent_result = intent.IntentResponse()

View File

@ -43,7 +43,7 @@ def async_setup(hass, config):
hass.http.register_view(ClearCompletedItemsView)
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, [
'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)
if state is None:
raise IntentHandleError('Unable to find entity {}'.format(name))
raise IntentHandleError(
'Unable to find an entity called {}'.format(name))
return state

View File

@ -237,7 +237,7 @@ def test_http_api(hass, test_client):
calls = async_mock_service(hass, 'homeassistant', 'turn_on')
resp = yield from client.post('/api/conversation/process', json={
'text': 'Turn kitchen on'
'text': 'Turn the kitchen on'
})
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={
})
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()
assert response.speech['plain']['speech'] == 'Turned on test light'
assert response.speech['plain']['speech'] == 'Turned test light on'
assert len(calls) == 1
call = calls[0]
assert call.domain == 'light'
@ -234,7 +234,7 @@ def test_turn_off_intent(hass):
)
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
call = calls[0]
assert call.domain == 'light'
@ -283,7 +283,7 @@ def test_turn_on_multiple_intent(hass):
)
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
call = calls[0]
assert call.domain == 'light'