Use intent responses from home-assistant-intents (#86484)

* Use intent responses from home_assistant_intents

* Use error responses from home_assistant_intents

* Remove speech checks for intent tests (set by conversation now)

* Bump hassil and home-assistant-intents versions

* Use Home Assistant JSON reader when loading intents

* Remove speech checks for light tests (done in agent)

* Add more tests for code coverage

* Add test for reloading on new component

* Add test for non-default response
This commit is contained in:
Michael Hansen 2023-01-23 21:38:41 -06:00 committed by GitHub
parent 6d811d3fdb
commit ea95abcb30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 236 additions and 134 deletions

View file

@ -294,7 +294,7 @@ class AlmondAgent(conversation.AbstractConversationAgent):
context: Context,
conversation_id: str | None = None,
language: str | None = None,
) -> conversation.ConversationResult | None:
) -> conversation.ConversationResult:
"""Process a sentence."""
response = await self.api.async_converse_text(text, conversation_id)
language = language or self.hass.config.language

View file

@ -242,44 +242,5 @@ async def async_converse(
if language is None:
language = hass.config.language
result: ConversationResult | None = None
intent_response: intent.IntentResponse | None = None
try:
result = await agent.async_process(text, context, conversation_id, language)
except intent.IntentHandleError as err:
# Match was successful, but target(s) were invalid
intent_response = intent.IntentResponse(language=language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
str(err),
)
except intent.IntentUnexpectedError as err:
# Match was successful, but an error occurred while handling intent
intent_response = intent.IntentResponse(language=language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
str(err),
)
except intent.IntentError as err:
# Unknown error
intent_response = intent.IntentResponse(language=language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
str(err),
)
if result is None:
if intent_response is None:
# Match was not successful
intent_response = intent.IntentResponse(language=language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
"Sorry, I didn't understand that",
)
result = ConversationResult(
response=intent_response, conversation_id=conversation_id
)
result = await agent.async_process(text, context, conversation_id, language)
return result

View file

@ -47,7 +47,7 @@ class AbstractConversationAgent(ABC):
context: Context,
conversation_id: str | None = None,
language: str | None = None,
) -> ConversationResult | None:
) -> ConversationResult:
"""Process a sentence."""
async def async_reload(self, language: str | None = None):

View file

@ -8,31 +8,40 @@ from dataclasses import dataclass
import logging
from pathlib import Path
import re
from typing import Any
from typing import IO, Any
from hassil.intents import Intents, SlotList, TextSlotList
from hassil.intents import Intents, ResponseType, SlotList, TextSlotList
from hassil.recognize import recognize
from hassil.util import merge_dict
from home_assistant_intents import get_intents
import yaml
from homeassistant import core, setup
from homeassistant.helpers import area_registry, entity_registry, intent
from homeassistant.helpers import area_registry, entity_registry, intent, template
from homeassistant.helpers.json import json_loads
from .agent import AbstractConversationAgent, ConversationResult
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
REGEX_TYPE = type(re.compile(""))
def json_load(fp: IO[str]) -> dict[str, Any]:
"""Wrap json_loads for get_intents."""
return json_loads(fp.read())
@dataclass
class LanguageIntents:
"""Loaded intents for a language."""
intents: Intents
intents_dict: dict[str, Any]
intent_responses: dict[str, Any]
error_responses: dict[str, Any]
loaded_components: set[str]
@ -78,7 +87,7 @@ class DefaultAgent(AbstractConversationAgent):
context: core.Context,
conversation_id: str | None = None,
language: str | None = None,
) -> ConversationResult | None:
) -> ConversationResult:
"""Process a sentence."""
language = language or self.hass.config.language
lang_intents = self._lang_intents.get(language)
@ -93,7 +102,12 @@ class DefaultAgent(AbstractConversationAgent):
if lang_intents is None:
# No intents loaded
_LOGGER.warning("No intents were loaded for language: %s", language)
return None
return _make_error_result(
language,
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
_DEFAULT_ERROR_TEXT,
conversation_id,
)
slot_lists: dict[str, SlotList] = {
"area": self._make_areas_list(),
@ -102,17 +116,65 @@ class DefaultAgent(AbstractConversationAgent):
result = recognize(text, lang_intents.intents, slot_lists=slot_lists)
if result is None:
return None
_LOGGER.debug("No intent was matched for '%s'", text)
return _make_error_result(
language,
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
self._get_error_text(ResponseType.NO_INTENT, lang_intents),
conversation_id,
)
intent_response = await intent.async_handle(
self.hass,
DOMAIN,
result.intent.name,
{entity.name: {"value": entity.value} for entity in result.entities_list},
text,
context,
language,
)
try:
intent_response = await intent.async_handle(
self.hass,
DOMAIN,
result.intent.name,
{
entity.name: {"value": entity.value}
for entity in result.entities_list
},
text,
context,
language,
)
except intent.IntentHandleError:
_LOGGER.exception("Intent handling error")
return _make_error_result(
language,
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
self._get_error_text(ResponseType.HANDLE_ERROR, lang_intents),
conversation_id,
)
except intent.IntentUnexpectedError:
_LOGGER.exception("Unexpected intent error")
return _make_error_result(
language,
intent.IntentResponseErrorCode.UNKNOWN,
self._get_error_text(ResponseType.HANDLE_ERROR, lang_intents),
conversation_id,
)
if (
(not intent_response.speech)
and (intent_response.intent is not None)
and (response_key := result.response)
):
# Use response template, if available
response_str = lang_intents.intent_responses.get(
result.intent.name, {}
).get(response_key)
if response_str:
response_template = template.Template(response_str, self.hass)
intent_response.async_set_speech(
response_template.async_render(
{
"slots": {
entity_name: entity_value.text or entity_value.value
for entity_name, entity_value in result.entities.items()
}
}
)
)
return ConversationResult(
response=intent_response, conversation_id=conversation_id
@ -168,7 +230,9 @@ class DefaultAgent(AbstractConversationAgent):
# Check for intents for this component with the target language.
# Try en-US, en, etc.
for language_variation in _get_language_variations(language):
component_intents = get_intents(component, language_variation)
component_intents = get_intents(
component, language_variation, json_load=json_load
)
if component_intents:
# Merge sentences into existing dictionary
merge_dict(intents_dict, component_intents)
@ -230,11 +294,24 @@ class DefaultAgent(AbstractConversationAgent):
# components with sentences are often being loaded.
intents = Intents.from_dict(intents_dict)
# Load responses
responses_dict = intents_dict.get("responses", {})
intent_responses = responses_dict.get("intents", {})
error_responses = responses_dict.get("errors", {})
if lang_intents is None:
lang_intents = LanguageIntents(intents, intents_dict, loaded_components)
lang_intents = LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
loaded_components,
)
self._lang_intents[language] = lang_intents
else:
lang_intents.intents = intents
lang_intents.intent_responses = intent_responses
lang_intents.error_responses = error_responses
return lang_intents
@ -256,6 +333,9 @@ class DefaultAgent(AbstractConversationAgent):
registry = entity_registry.async_get(self.hass)
names = []
for state in states:
domain = state.entity_id.split(".", maxsplit=1)[0]
context = {"domain": domain}
entry = registry.async_get(state.entity_id)
if entry is not None:
if entry.entity_category:
@ -264,9 +344,30 @@ class DefaultAgent(AbstractConversationAgent):
if entry.aliases:
for alias in entry.aliases:
names.append((alias, state.entity_id))
names.append((alias, state.entity_id, context))
# Default name
names.append((state.name, state.entity_id))
names.append((state.name, state.entity_id, context))
return TextSlotList.from_tuples(names)
def _get_error_text(
self, response_type: ResponseType, lang_intents: LanguageIntents
) -> str:
"""Get response error text by type."""
response_key = response_type.value
response_str = lang_intents.error_responses.get(response_key)
return response_str or _DEFAULT_ERROR_TEXT
def _make_error_result(
language: str,
error_code: intent.IntentResponseErrorCode,
response_text: str,
conversation_id: str | None = None,
) -> ConversationResult:
"""Create conversation result with error code and text."""
response = intent.IntentResponse(language=language)
response.async_set_error(error_code, response_text)
return ConversationResult(response, conversation_id)

View file

@ -2,7 +2,7 @@
"domain": "conversation",
"name": "Conversation",
"documentation": "https://www.home-assistant.io/integrations/conversation",
"requirements": ["hassil==0.2.3", "home-assistant-intents==0.0.1"],
"requirements": ["hassil==0.2.5", "home-assistant-intents==2022.1.23"],
"dependencies": ["http"],
"codeowners": ["@home-assistant/core"],
"quality_scale": "internal",

View file

@ -131,7 +131,7 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent):
context: Context,
conversation_id: str | None = None,
language: str | None = None,
) -> conversation.ConversationResult | None:
) -> conversation.ConversationResult:
"""Process a sentence."""
if self.session:
session = self.session

View file

@ -31,21 +31,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
intent.async_register(
hass,
OnOffIntentHandler(
intent.INTENT_TURN_ON, HA_DOMAIN, SERVICE_TURN_ON, "Turned {} on"
),
OnOffIntentHandler(intent.INTENT_TURN_ON, HA_DOMAIN, SERVICE_TURN_ON),
)
intent.async_register(
hass,
OnOffIntentHandler(
intent.INTENT_TURN_OFF, HA_DOMAIN, SERVICE_TURN_OFF, "Turned {} off"
),
OnOffIntentHandler(intent.INTENT_TURN_OFF, HA_DOMAIN, SERVICE_TURN_OFF),
)
intent.async_register(
hass,
intent.ServiceIntentHandler(
intent.INTENT_TOGGLE, HA_DOMAIN, SERVICE_TOGGLE, "Toggled {}"
),
intent.ServiceIntentHandler(intent.INTENT_TOGGLE, HA_DOMAIN, SERVICE_TOGGLE),
)
return True

View file

@ -47,7 +47,6 @@ class SetIntentHandler(intent.IntentHandler):
"""Handle the hass intent."""
hass = intent_obj.hass
service_data: dict[str, Any] = {}
speech_parts: list[str] = []
slots = self.async_validate_slots(intent_obj.slots)
name: str | None = slots.get("name", {}).get("value")
@ -92,13 +91,9 @@ class SetIntentHandler(intent.IntentHandler):
if "color" in slots:
service_data[ATTR_RGB_COLOR] = slots["color"]["value"]
# Use original passed in value of the color because we don't have
# human readable names for that internally.
speech_parts.append(f"the color {intent_obj.slots['color']['value']}")
if "brightness" in slots:
service_data[ATTR_BRIGHTNESS_PCT] = slots["brightness"]["value"]
speech_parts.append(f"{slots['brightness']['value']}% brightness")
response = intent_obj.create_response()
needs_brightness = ATTR_BRIGHTNESS_PCT in service_data
@ -116,9 +111,6 @@ class SetIntentHandler(intent.IntentHandler):
id=area.id,
)
)
speech_name = area.name
else:
speech_name = states[0].name
for state in states:
target = intent.IntentResponseTarget(
@ -152,19 +144,4 @@ class SetIntentHandler(intent.IntentHandler):
success_results=success_results, failed_results=failed_results
)
if not speech_parts: # No attributes changed
speech = f"Turned on {speech_name}"
else:
parts = [f"Changed {speech_name} to"]
for index, part in enumerate(speech_parts):
if index == 0:
parts.append(f" {part}")
elif index != len(speech_parts) - 1:
parts.append(f", {part}")
else:
parts.append(f" and {part}")
speech = "".join(parts)
response.async_set_speech(speech)
return response

View file

@ -286,7 +286,7 @@ class ServiceIntentHandler(IntentHandler):
}
def __init__(
self, intent_type: str, domain: str, service: str, speech: str
self, intent_type: str, domain: str, service: str, speech: str | None = None
) -> None:
"""Create Service Intent Handler."""
self.intent_type = intent_type
@ -382,7 +382,9 @@ class ServiceIntentHandler(IntentHandler):
response.async_set_results(
success_results=success_results,
)
response.async_set_speech(self.speech.format(speech_name))
if self.speech is not None:
response.async_set_speech(self.speech.format(speech_name))
return response

View file

@ -21,10 +21,10 @@ cryptography==39.0.0
dbus-fast==1.84.0
fnvhash==0.1.0
hass-nabucasa==0.61.0
hassil==0.2.3
hassil==0.2.5
home-assistant-bluetooth==1.9.2
home-assistant-frontend==20230110.0
home-assistant-intents==0.0.1
home-assistant-intents==2022.1.23
httpx==0.23.2
ifaddr==0.1.7
janus==1.0.0

View file

@ -877,7 +877,7 @@ hass-nabucasa==0.61.0
hass_splunk==0.1.1
# homeassistant.components.conversation
hassil==0.2.3
hassil==0.2.5
# homeassistant.components.tasmota
hatasmota==0.6.3
@ -913,7 +913,7 @@ holidays==0.18.0
home-assistant-frontend==20230110.0
# homeassistant.components.conversation
home-assistant-intents==0.0.1
home-assistant-intents==2022.1.23
# homeassistant.components.home_connect
homeconnect==0.7.2

View file

@ -669,7 +669,7 @@ habitipy==0.2.0
hass-nabucasa==0.61.0
# homeassistant.components.conversation
hassil==0.2.3
hassil==0.2.5
# homeassistant.components.tasmota
hatasmota==0.6.3
@ -696,7 +696,7 @@ holidays==0.18.0
home-assistant-frontend==20230110.0
# homeassistant.components.conversation
home-assistant-intents==0.0.1
home-assistant-intents==2022.1.23
# homeassistant.components.home_connect
homeconnect==0.7.2

View file

@ -20,7 +20,7 @@ class MockAgent(conversation.AbstractConversationAgent):
context: Context,
conversation_id: str | None = None,
language: str | None = None,
) -> conversation.ConversationResult | None:
) -> conversation.ConversationResult:
"""Process some text."""
self.calls.append((text, context, conversation_id, language))
response = intent.IntentResponse(language=language)

View file

@ -5,8 +5,9 @@ from unittest.mock import ANY, patch
import pytest
from homeassistant.components import conversation
from homeassistant.core import DOMAIN as HASS_DOMAIN
from homeassistant.helpers import intent
from homeassistant.components.cover import SERVICE_OPEN_COVER
from homeassistant.core import DOMAIN as HASS_DOMAIN, Context
from homeassistant.helpers import entity_registry, intent
from homeassistant.setup import async_setup_component
from tests.common import async_mock_service
@ -37,10 +38,15 @@ async def test_http_processing_intent(
hass, init_components, hass_client, hass_admin_user
):
"""Test processing intent via HTTP API."""
hass.states.async_set("light.kitchen", "on")
# Add an alias
entities = entity_registry.async_get(hass)
entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen")
entities.async_update_entity("light.kitchen", aliases={"my cool light"})
hass.states.async_set("light.kitchen", "off")
client = await hass_client()
resp = await client.post(
"/api/conversation/process", json={"text": "turn on kitchen"}
"/api/conversation/process", json={"text": "turn on my cool light"}
)
assert resp.status == HTTPStatus.OK
@ -53,7 +59,7 @@ async def test_http_processing_intent(
"speech": {
"plain": {
"extra_data": None,
"speech": "Turned kitchen on",
"speech": "Turned on my cool light",
}
},
"language": hass.config.language,
@ -120,7 +126,7 @@ async def test_http_api(hass, init_components, hass_client):
assert data == {
"response": {
"card": {},
"speech": {"plain": {"extra_data": None, "speech": "Turned kitchen on"}},
"speech": {"plain": {"extra_data": None, "speech": "Turned on kitchen"}},
"language": hass.config.language,
"response_type": "action_done",
"data": {
@ -161,7 +167,7 @@ async def test_http_api_no_match(hass, init_components, hass_client):
"card": {},
"speech": {
"plain": {
"speech": "Sorry, I didn't understand that",
"speech": "Sorry, I couldn't understand that",
"extra_data": None,
},
},
@ -178,11 +184,9 @@ async def test_http_api_handle_failure(hass, init_components, hass_client):
hass.states.async_set("light.kitchen", "off")
# Raise an "unexpected" error during intent handling
# Raise an error during intent handling
def async_handle_error(*args, **kwargs):
raise intent.IntentUnexpectedError(
"Unexpected error turning on the kitchen light"
)
raise intent.IntentHandleError()
with patch("homeassistant.helpers.intent.async_handle", new=async_handle_error):
resp = await client.post(
@ -199,7 +203,7 @@ async def test_http_api_handle_failure(hass, init_components, hass_client):
"speech": {
"plain": {
"extra_data": None,
"speech": "Unexpected error turning on the kitchen light",
"speech": "An unexpected error occurred while handling the intent",
}
},
"language": hass.config.language,
@ -211,6 +215,43 @@ async def test_http_api_handle_failure(hass, init_components, hass_client):
}
async def test_http_api_unexpected_failure(hass, init_components, hass_client):
"""Test the HTTP conversation API with an unexpected error during handling."""
client = await hass_client()
hass.states.async_set("light.kitchen", "off")
# Raise an "unexpected" error during intent handling
def async_handle_error(*args, **kwargs):
raise intent.IntentUnexpectedError()
with patch("homeassistant.helpers.intent.async_handle", new=async_handle_error):
resp = await client.post(
"/api/conversation/process", json={"text": "turn on the kitchen"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == {
"response": {
"response_type": "error",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "An unexpected error occurred while handling the intent",
}
},
"language": hass.config.language,
"data": {
"code": "unknown",
},
},
"conversation_id": None,
}
async def test_http_api_wrong_data(hass, init_components, hass_client):
"""Test the HTTP conversation API."""
client = await hass_client()
@ -302,7 +343,7 @@ async def test_ws_api(hass, hass_ws_client, payload):
"speech": {
"plain": {
"extra_data": None,
"speech": "Sorry, I didn't understand that",
"speech": "Sorry, I couldn't understand that",
}
},
"language": payload.get("language", hass.config.language),
@ -484,3 +525,40 @@ async def test_language_region(hass, init_components):
assert call.domain == HASS_DOMAIN
assert call.service == "turn_on"
assert call.data == {"entity_id": "light.kitchen"}
async def test_reload_on_new_component(hass):
"""Test intents being reloaded when a new component is loaded."""
language = hass.config.language
assert await async_setup_component(hass, "conversation", {})
# Load intents
agent = await conversation._get_agent(hass)
assert isinstance(agent, conversation.DefaultAgent)
await agent.async_prepare()
lang_intents = agent._lang_intents.get(language)
assert lang_intents is not None
loaded_components = set(lang_intents.loaded_components)
# Load another component
assert await async_setup_component(hass, "light", {})
# Intents should reload
await agent.async_prepare()
lang_intents = agent._lang_intents.get(language)
assert lang_intents is not None
assert {"light"} == (lang_intents.loaded_components - loaded_components)
async def test_non_default_response(hass, init_components):
"""Test intent response that is not the default."""
hass.states.async_set("cover.front_door", "closed")
async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
agent = await conversation._get_agent(hass)
assert isinstance(agent, conversation.DefaultAgent)
result = await agent.async_process("open the front door", Context())
assert result.response.speech["plain"]["speech"] == "Opened front door"

View file

@ -96,12 +96,11 @@ async def test_turn_on_intent(hass):
hass.states.async_set("light.test_light", "off")
calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
response = await intent.async_handle(
await intent.async_handle(
hass, "test", "HassTurnOn", {"name": {"value": "test light"}}
)
await hass.async_block_till_done()
assert response.speech["plain"]["speech"] == "Turned test light on"
assert len(calls) == 1
call = calls[0]
assert call.domain == "light"
@ -118,12 +117,11 @@ async def test_turn_off_intent(hass):
hass.states.async_set("light.test_light", "on")
calls = async_mock_service(hass, "light", SERVICE_TURN_OFF)
response = await intent.async_handle(
await intent.async_handle(
hass, "test", "HassTurnOff", {"name": {"value": "test light"}}
)
await hass.async_block_till_done()
assert response.speech["plain"]["speech"] == "Turned test light off"
assert len(calls) == 1
call = calls[0]
assert call.domain == "light"
@ -140,12 +138,11 @@ async def test_toggle_intent(hass):
hass.states.async_set("light.test_light", "off")
calls = async_mock_service(hass, "light", SERVICE_TOGGLE)
response = await intent.async_handle(
await intent.async_handle(
hass, "test", "HassToggle", {"name": {"value": "test light"}}
)
await hass.async_block_till_done()
assert response.speech["plain"]["speech"] == "Toggled test light"
assert len(calls) == 1
call = calls[0]
assert call.domain == "light"
@ -167,12 +164,11 @@ async def test_turn_on_multiple_intent(hass):
hass.states.async_set("light.test_lighter", "off")
calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
response = await intent.async_handle(
await intent.async_handle(
hass, "test", "HassTurnOn", {"name": {"value": "test lights 2"}}
)
await hass.async_block_till_done()
assert response.speech["plain"]["speech"] == "Turned test lights 2 on"
assert len(calls) == 1
call = calls[0]
assert call.domain == "light"

View file

@ -16,7 +16,7 @@ async def test_intent_set_color(hass):
calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON)
await intent.async_setup_intents(hass)
result = await async_handle(
await async_handle(
hass,
"test",
intent.INTENT_SET,
@ -24,8 +24,6 @@ async def test_intent_set_color(hass):
)
await hass.async_block_till_done()
assert result.speech["plain"]["speech"] == "Changed hello 2 to the color blue"
assert len(calls) == 1
call = calls[0]
assert call.domain == light.DOMAIN
@ -62,7 +60,7 @@ async def test_intent_set_color_and_brightness(hass):
calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON)
await intent.async_setup_intents(hass)
result = await async_handle(
await async_handle(
hass,
"test",
intent.INTENT_SET,
@ -74,11 +72,6 @@ async def test_intent_set_color_and_brightness(hass):
)
await hass.async_block_till_done()
assert (
result.speech["plain"]["speech"]
== "Changed hello 2 to the color blue and 20% brightness"
)
assert len(calls) == 1
call = calls[0]
assert call.domain == light.DOMAIN