Update intent response (#83858)

* Add language to conversation and intent response

* Move language to intent response instead of speech

* Extend intent response for voice MVP

* Add tests for error conditions in conversation/process

* Move intent response type data into "data" field

* Move intent response error message back to speech

* Remove "success" from intent response

* Add id to target in intent response

* target defaults to None

* Update homeassistant/helpers/intent.py

* Fix test

* Return conversation_id and multiple targets

* Clean up git mess

* Fix linting errors

* Fix more async_handle signatures

* Separate conversation_id and IntentResponse

* Add unknown error code

* Add ConversationResult

* Don't set domain on single entity

* Language is required for intent response

* Add partial_action_done

* Default language in almond agent

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Michael Hansen 2022-12-13 16:46:40 -06:00 committed by GitHub
parent 0e2ebfe5c4
commit 961c8cc167
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 301 additions and 141 deletions

View File

@ -291,9 +291,10 @@ class AlmondAgent(conversation.AbstractConversationAgent):
context: Context,
conversation_id: str | None = None,
language: str | None = None,
) -> intent.IntentResponse:
) -> conversation.ConversationResult | None:
"""Process a sentence."""
response = await self.api.async_converse_text(text, conversation_id)
language = language or self.hass.config.language
first_choice = True
buffer = ""
@ -314,6 +315,8 @@ class AlmondAgent(conversation.AbstractConversationAgent):
buffer += ","
buffer += f" {message['title']}"
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_speech(buffer.strip())
return intent_result
intent_response = intent.IntentResponse(language=language)
intent_response.async_set_speech(buffer.strip())
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)

View File

@ -1,7 +1,6 @@
"""Support for functionality to have conversations with Home Assistant."""
from __future__ import annotations
from http import HTTPStatus
import logging
import re
from typing import Any
@ -16,7 +15,7 @@ from homeassistant.helpers import config_validation as cv, intent
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from .agent import AbstractConversationAgent
from .agent import AbstractConversationAgent, ConversationResult
from .default_agent import DefaultAgent, async_register
_LOGGER = logging.getLogger(__name__)
@ -101,16 +100,14 @@ async def websocket_process(
msg: dict[str, Any],
) -> None:
"""Process text."""
connection.send_result(
msg["id"],
await _async_converse(
hass,
msg["text"],
msg.get("conversation_id"),
connection.context(msg),
msg.get("language"),
),
result = await _async_converse(
hass,
msg["text"],
msg.get("conversation_id"),
connection.context(msg),
msg.get("language"),
)
connection.send_result(msg["id"], result.as_dict())
@websocket_api.websocket_command({"type": "conversation/agent/info"})
@ -168,29 +165,15 @@ class ConversationProcessView(http.HomeAssistantView):
async def post(self, request, data):
"""Send a request for processing."""
hass = request.app["hass"]
result = await _async_converse(
hass,
text=data["text"],
conversation_id=data.get("conversation_id"),
context=self.context(request),
language=data.get("language"),
)
try:
intent_result = await _async_converse(
hass,
text=data["text"],
conversation_id=data.get("conversation_id"),
context=self.context(request),
language=data.get("language"),
)
except intent.IntentError as err:
_LOGGER.error("Error handling intent: %s", err)
return self.json(
{
"success": False,
"error": {
"code": str(err.__class__.__name__).lower(),
"message": str(err),
},
},
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
)
return self.json(intent_result)
return self.json(result.as_dict())
async def _get_agent(hass: core.HomeAssistant) -> AbstractConversationAgent:
@ -207,37 +190,50 @@ async def _async_converse(
conversation_id: str | None,
context: core.Context,
language: str | None = None,
) -> intent.IntentResponse:
) -> ConversationResult:
"""Process text and get intent."""
agent = await _get_agent(hass)
if language is None:
language = hass.config.language
result: ConversationResult | None = None
intent_response: intent.IntentResponse | None = None
try:
intent_result = await agent.async_process(
text, context, conversation_id, language
)
result = await agent.async_process(text, context, conversation_id, language)
except intent.IntentHandleError as err:
# Match was successful, but target(s) were invalid
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_error(
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_result = intent.IntentResponse(language=language)
intent_result.async_set_error(
intent_response = intent.IntentResponse(language=language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
str(err),
)
if intent_result is None:
# Match was not successful
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_error(
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
"Sorry, I didn't understand that",
except intent.IntentError as err:
# Unknown error
intent_response = intent.IntentResponse(language=language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
str(err),
)
return intent_result
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
)
return result

View File

@ -2,11 +2,28 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
from homeassistant.core import Context
from homeassistant.helpers import intent
@dataclass
class ConversationResult:
"""Result of async_process."""
response: intent.IntentResponse
conversation_id: str | None = None
def as_dict(self) -> dict[str, Any]:
"""Return result as a dict."""
return {
"response": self.response.as_dict(),
"conversation_id": self.conversation_id,
}
class AbstractConversationAgent(ABC):
"""Abstract conversation agent."""
@ -30,5 +47,5 @@ class AbstractConversationAgent(ABC):
context: Context,
conversation_id: str | None = None,
language: str | None = None,
) -> intent.IntentResponse | None:
) -> ConversationResult | None:
"""Process a sentence."""

View File

@ -14,7 +14,7 @@ from homeassistant.core import callback
from homeassistant.helpers import intent
from homeassistant.setup import ATTR_COMPONENT
from .agent import AbstractConversationAgent
from .agent import AbstractConversationAgent, ConversationResult
from .const import DOMAIN
from .util import create_matcher
@ -116,7 +116,7 @@ class DefaultAgent(AbstractConversationAgent):
context: core.Context,
conversation_id: str | None = None,
language: str | None = None,
) -> intent.IntentResponse | None:
) -> ConversationResult | None:
"""Process a sentence."""
intents = self.hass.data[DOMAIN]
@ -125,7 +125,7 @@ class DefaultAgent(AbstractConversationAgent):
if not (match := matcher.match(text)):
continue
return await intent.async_handle(
intent_response = await intent.async_handle(
self.hass,
DOMAIN,
intent_type,
@ -135,4 +135,8 @@ class DefaultAgent(AbstractConversationAgent):
language,
)
return ConversationResult(
response=intent_response, conversation_id=conversation_id
)
return None

View File

@ -1,4 +1,6 @@
"""Intents for the humidifier integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, STATE_OFF

View File

@ -63,6 +63,7 @@ class IntentHandleView(http.HomeAssistantView):
async def post(self, request, data):
"""Handle intent with name/data."""
hass = request.app["hass"]
language = hass.config.language
try:
intent_name = data["name"]
@ -73,11 +74,11 @@ class IntentHandleView(http.HomeAssistantView):
hass, DOMAIN, intent_name, slots, "", self.context(request)
)
except intent.IntentHandleError as err:
intent_result = intent.IntentResponse()
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_speech(str(err))
if intent_result is None:
intent_result = intent.IntentResponse()
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_speech("Sorry, I couldn't handle that")
return self.json(intent_result)

View File

@ -1,4 +1,6 @@
"""Handle intents with scripts."""
from __future__ import annotations
import copy
import logging
@ -77,7 +79,7 @@ class ScriptIntentHandler(intent.IntentHandler):
self.intent_type = intent_type
self.config = config
async def async_handle(self, intent_obj):
async def async_handle(self, intent_obj: intent.Intent):
"""Handle the intent."""
speech = self.config.get(CONF_SPEECH)
reprompt = self.config.get(CONF_REPROMPT)

View File

@ -1,4 +1,6 @@
"""Intents for the light integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON

View File

@ -1,4 +1,6 @@
"""Intents for the Shopping List integration."""
from __future__ import annotations
from homeassistant.helpers import intent
import homeassistant.helpers.config_validation as cv
@ -20,7 +22,7 @@ class AddItemIntent(intent.IntentHandler):
intent_type = INTENT_ADD_ITEM
slot_schema = {"item": cv.string}
async def async_handle(self, intent_obj):
async def async_handle(self, intent_obj: intent.Intent):
"""Handle the intent."""
slots = self.async_validate_slots(intent_obj.slots)
item = slots["item"]["value"]
@ -38,7 +40,7 @@ class ListTopItemsIntent(intent.IntentHandler):
intent_type = INTENT_LAST_ITEMS
slot_schema = {"item": cv.string}
async def async_handle(self, intent_obj):
async def async_handle(self, intent_obj: intent.Intent):
"""Handle the intent."""
items = intent_obj.hass.data[DOMAIN].items[-5:]
response = intent_obj.create_response()

View File

@ -221,12 +221,14 @@ class ServiceIntentHandler(IntentHandler):
response = intent_obj.create_response()
response.async_set_speech(self.speech.format(state.name))
response.async_set_target(
IntentResponseTarget(
name=state.name,
type=IntentResponseTargetType.ENTITY,
id=state.entity_id,
)
response.async_set_targets(
[
IntentResponseTarget(
type=IntentResponseTargetType.ENTITY,
name=state.name,
id=state.entity_id,
),
],
)
return response
@ -279,7 +281,7 @@ class Intent:
@callback
def create_response(self) -> IntentResponse:
"""Create a response."""
return IntentResponse(self, language=self.language)
return IntentResponse(language=self.language, intent=self)
class IntentResponseType(Enum):
@ -288,6 +290,9 @@ class IntentResponseType(Enum):
ACTION_DONE = "action_done"
"""Intent caused an action to occur"""
PARTIAL_ACTION_DONE = "partial_action_done"
"""Intent caused an action, but it could only be partially done"""
QUERY_ANSWER = "query_answer"
"""Response is an answer to a query"""
@ -307,6 +312,9 @@ class IntentResponseErrorCode(str, Enum):
FAILED_TO_HANDLE = "failed_to_handle"
"""Unexpected error occurred while handling intent"""
UNKNOWN = "unknown"
"""Error outside the scope of intent processing"""
class IntentResponseTargetType(str, Enum):
"""Type of target for an intent response."""
@ -314,12 +322,14 @@ class IntentResponseTargetType(str, Enum):
AREA = "area"
DEVICE = "device"
ENTITY = "entity"
OTHER = "other"
DOMAIN = "domain"
DEVICE_CLASS = "device_class"
CUSTOM = "custom"
@dataclass
class IntentResponseTarget:
"""Main target of the intent response."""
"""Target of the intent response."""
name: str
type: IntentResponseTargetType
@ -331,17 +341,19 @@ class IntentResponse:
def __init__(
self,
language: str,
intent: Intent | None = None,
language: str | None = None,
) -> None:
"""Initialize an IntentResponse."""
self.language = language
self.intent = intent
self.speech: dict[str, dict[str, Any]] = {}
self.reprompt: dict[str, dict[str, Any]] = {}
self.card: dict[str, dict[str, str]] = {}
self.language = language
self.error_code: IntentResponseErrorCode | None = None
self.target: IntentResponseTarget | None = None
self.targets: list[IntentResponseTarget] = []
self.success_targets: list[IntentResponseTarget] = []
self.failed_targets: list[IntentResponseTarget] = []
if (self.intent is not None) and (self.intent.category == IntentCategory.QUERY):
# speech will be the answer to the query
@ -392,9 +404,20 @@ class IntentResponse:
self.async_set_speech(message)
@callback
def async_set_target(self, target: IntentResponseTarget) -> None:
"""Set response target."""
self.target = target
def async_set_targets(self, targets: list[IntentResponseTarget]) -> None:
"""Set response targets."""
self.targets = targets
@callback
def async_set_partial_action_done(
self,
success_targets: list[IntentResponseTarget],
failed_targets: list[IntentResponseTarget],
) -> None:
"""Set response targets."""
self.response_type = IntentResponseType.PARTIAL_ACTION_DONE
self.success_targets = success_targets
self.failed_targets = failed_targets
@callback
def as_dict(self) -> dict[str, Any]:
@ -416,9 +439,19 @@ class IntentResponse:
response_data["code"] = self.error_code.value
else:
# action done or query answer
response_data["target"] = (
dataclasses.asdict(self.target) if self.target is not None else None
)
response_data["targets"] = [
dataclasses.asdict(target) for target in self.targets
]
if self.response_type == IntentResponseType.PARTIAL_ACTION_DONE:
# Add success/failed targets
response_data["success"] = [
dataclasses.asdict(target) for target in self.success_targets
]
response_data["failed"] = [
dataclasses.asdict(target) for target in self.failed_targets
]
response_dict["data"] = response_data

View File

@ -131,18 +131,97 @@ async def test_http_processing_intent(hass, hass_client, hass_admin_user):
data = await resp.json()
assert data == {
"response_type": "action_done",
"card": {
"simple": {"content": "You chose a Grolsch.", "title": "Beer ordered"}
"response": {
"response_type": "action_done",
"card": {
"simple": {"content": "You chose a Grolsch.", "title": "Beer ordered"}
},
"speech": {
"plain": {
"extra_data": None,
"speech": "I've ordered a Grolsch!",
}
},
"language": hass.config.language,
"data": {"targets": []},
},
"speech": {
"plain": {
"extra_data": None,
"speech": "I've ordered a Grolsch!",
"conversation_id": None,
}
async def test_http_partial_action(hass, hass_client, hass_admin_user):
"""Test processing intent via HTTP API with a partial completion."""
class TestIntentHandler(intent.IntentHandler):
"""Test Intent Handler."""
intent_type = "TurnOffLights"
async def async_handle(self, handle_intent: intent.Intent):
"""Handle the intent."""
response = handle_intent.create_response()
area = handle_intent.slots["area"]["value"]
response.async_set_targets(
[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.AREA, name=area, id=area
)
]
)
# Mark some targets as successful, others as failed
response.async_set_partial_action_done(
success_targets=[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name="light1",
id="light.light1",
)
],
failed_targets=[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name="light2",
id="light.light2",
)
],
)
return response
intent.async_register(hass, TestIntentHandler())
result = await async_setup_component(
hass,
"conversation",
{
"conversation": {
"intents": {"TurnOffLights": ["turn off the lights in the {area}"]}
}
},
"language": hass.config.language,
"data": {"target": None},
)
assert result
client = await hass_client()
resp = await client.post(
"/api/conversation/process", json={"text": "Turn off the lights in the kitchen"}
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data == {
"response": {
"response_type": "partial_action_done",
"card": {},
"speech": {},
"language": hass.config.language,
"data": {
"targets": [{"type": "area", "id": "kitchen", "name": "kitchen"}],
"success": [{"type": "entity", "id": "light.light1", "name": "light1"}],
"failed": [{"type": "entity", "id": "light.light2", "name": "light2"}],
},
},
"conversation_id": None,
}
@ -213,17 +292,22 @@ async def test_http_api(hass, init_components, hass_client):
data = await resp.json()
assert data == {
"card": {},
"speech": {"plain": {"extra_data": None, "speech": "Turned kitchen on"}},
"language": hass.config.language,
"response_type": "action_done",
"data": {
"target": {
"name": "kitchen",
"type": "entity",
"id": "light.kitchen",
}
"response": {
"card": {},
"speech": {"plain": {"extra_data": None, "speech": "Turned kitchen on"}},
"language": hass.config.language,
"response_type": "action_done",
"data": {
"targets": [
{
"type": "entity",
"name": "kitchen",
"id": "light.kitchen",
},
]
},
},
"conversation_id": None,
}
assert len(calls) == 1
@ -243,18 +327,21 @@ async def test_http_api_no_match(hass, init_components, hass_client):
data = await resp.json()
assert data == {
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Sorry, I didn't understand that",
"response": {
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Sorry, I didn't understand that",
},
},
"language": hass.config.language,
"response_type": "error",
"data": {
"code": "no_intent_match",
},
},
"language": hass.config.language,
"response_type": "error",
"data": {
"code": "no_intent_match",
},
"conversation_id": None,
}
@ -270,18 +357,21 @@ async def test_http_api_no_valid_targets(hass, init_components, hass_client):
data = await resp.json()
assert data == {
"response_type": "error",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Unable to find an entity called kitchen",
"response": {
"response_type": "error",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Unable to find an entity called kitchen",
},
},
"language": hass.config.language,
"data": {
"code": "no_valid_targets",
},
},
"language": hass.config.language,
"data": {
"code": "no_valid_targets",
},
"conversation_id": None,
}
@ -306,18 +396,21 @@ async def test_http_api_handle_failure(hass, init_components, hass_client):
data = await resp.json()
assert data == {
"response_type": "error",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Unexpected error turning on the kitchen light",
}
},
"language": hass.config.language,
"data": {
"code": "failed_to_handle",
"response": {
"response_type": "error",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Unexpected error turning on the kitchen light",
}
},
"language": hass.config.language,
"data": {
"code": "failed_to_handle",
},
},
"conversation_id": None,
}
@ -345,7 +438,9 @@ async def test_custom_agent(hass, hass_client, hass_admin_user):
calls.append((text, context, conversation_id, language))
response = intent.IntentResponse(language=language)
response.async_set_speech("Test response")
return response
return conversation.ConversationResult(
response=response, conversation_id=conversation_id
)
conversation.async_set_agent(hass, MyAgent())
@ -363,16 +458,19 @@ async def test_custom_agent(hass, hass_client, hass_admin_user):
)
assert resp.status == HTTPStatus.OK
assert await resp.json() == {
"response_type": "action_done",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Test response",
}
"response": {
"response_type": "action_done",
"card": {},
"speech": {
"plain": {
"extra_data": None,
"speech": "Test response",
}
},
"language": "test-language",
"data": {"targets": []},
},
"language": "test-language",
"data": {"target": None},
"conversation_id": "test-conv-id",
}
assert len(calls) == 1

View File

@ -54,7 +54,7 @@ async def test_http_handle_intent(hass, hass_client, hass_admin_user):
},
"language": hass.config.language,
"response_type": "action_done",
"data": {"target": None},
"data": {"targets": []},
}