Pass slots to error messages instead of IDs [rework] (#109410)

Co-authored-by: tetele <tm.sandu@gmail.com>
This commit is contained in:
Michael Hansen 2024-02-03 05:14:33 -06:00 committed by GitHub
parent 29556465de
commit c6ea57458c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 95 additions and 62 deletions

View File

@ -349,7 +349,7 @@ async def websocket_hass_agent_debug(
}, },
# Slot values that would be received by the intent # Slot values that would be received by the intent
"slots": { # direct access to values "slots": { # direct access to values
entity_key: entity.value entity_key: entity.text or entity.value
for entity_key, entity in result.entities.items() for entity_key, entity in result.entities.items()
}, },
# Extra slot details, such as the originally matched text # Extra slot details, such as the originally matched text

View File

@ -1,4 +1,5 @@
"""Standard conversation implementation for Home Assistant.""" """Standard conversation implementation for Home Assistant."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -264,9 +265,11 @@ class DefaultAgent(AbstractConversationAgent):
_LOGGER.debug( _LOGGER.debug(
"Recognized intent '%s' for template '%s' but had unmatched: %s", "Recognized intent '%s' for template '%s' but had unmatched: %s",
result.intent.name, result.intent.name,
(
result.intent_sentence.text result.intent_sentence.text
if result.intent_sentence is not None if result.intent_sentence is not None
else "", else ""
),
result.unmatched_entities_list, result.unmatched_entities_list,
) )
error_response_type, error_response_args = _get_unmatched_response(result) error_response_type, error_response_args = _get_unmatched_response(result)
@ -285,7 +288,8 @@ class DefaultAgent(AbstractConversationAgent):
# Slot values to pass to the intent # Slot values to pass to the intent
slots = { slots = {
entity.name: {"value": entity.value} for entity in result.entities_list entity.name: {"value": entity.value, "text": entity.text or entity.value}
for entity in result.entities_list
} }
try: try:
@ -474,9 +478,11 @@ class DefaultAgent(AbstractConversationAgent):
for entity_name, entity_value in recognize_result.entities.items() for entity_name, entity_value in recognize_result.entities.items()
}, },
# First matched or unmatched state # First matched or unmatched state
"state": template.TemplateState(self.hass, state1) "state": (
template.TemplateState(self.hass, state1)
if state1 is not None if state1 is not None
else None, else None
),
"query": { "query": {
# Entity states that matched the query (e.g, "on") # Entity states that matched the query (e.g, "on")
"matched": [ "matched": [
@ -734,7 +740,7 @@ class DefaultAgent(AbstractConversationAgent):
if not entity: if not entity:
# Default name # Default name
entity_names.append((state.name, state.name, context)) entity_names.append((state.name, state.entity_id, context))
continue continue
if entity.aliases: if entity.aliases:
@ -742,10 +748,10 @@ class DefaultAgent(AbstractConversationAgent):
if not alias.strip(): if not alias.strip():
continue continue
entity_names.append((alias, alias, context)) entity_names.append((alias, state.entity_id, context))
# Default name # Default name
entity_names.append((state.name, state.name, context)) entity_names.append((state.name, state.entity_id, context))
# Expose all areas # Expose all areas
areas = ar.async_get(self.hass) areas = ar.async_get(self.hass)
@ -785,7 +791,7 @@ class DefaultAgent(AbstractConversationAgent):
if device_area is None: if device_area is None:
return None return None
return {"area": device_area.id} return {"area": {"value": device_area.id, "text": device_area.name}}
def _get_error_text( def _get_error_text(
self, self,

View File

@ -7,5 +7,5 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "local_push", "iot_class": "local_push",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==1.6.0", "home-assistant-intents==2024.2.2"] "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.2"]
} }

View File

@ -1,4 +1,5 @@
"""Module to coordinate user intentions.""" """Module to coordinate user intentions."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -401,17 +402,21 @@ class ServiceIntentHandler(IntentHandler):
hass = intent_obj.hass hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
name: str | None = slots.get("name", {}).get("value") name_slot = slots.get("name", {})
if name == "all": entity_id: str | None = name_slot.get("value")
entity_name: str | None = name_slot.get("text")
if entity_id == "all":
# Don't match on name if targeting all entities # Don't match on name if targeting all entities
name = None entity_id = None
# Look up area first to fail early # Look up area first to fail early
area_name = slots.get("area", {}).get("value") area_slot = slots.get("area", {})
area_id = area_slot.get("value")
area_name = area_slot.get("text")
area: area_registry.AreaEntry | None = None area: area_registry.AreaEntry | None = None
if area_name is not None: if area_id is not None:
areas = area_registry.async_get(hass) areas = area_registry.async_get(hass)
area = areas.async_get_area(area_name) or areas.async_get_area_by_name( area = areas.async_get_area(area_id) or areas.async_get_area_by_name(
area_name area_name
) )
if area is None: if area is None:
@ -431,7 +436,7 @@ class ServiceIntentHandler(IntentHandler):
states = list( states = list(
async_match_states( async_match_states(
hass, hass,
name=name, name=entity_id,
area=area, area=area,
domains=domains, domains=domains,
device_classes=device_classes, device_classes=device_classes,
@ -442,8 +447,8 @@ class ServiceIntentHandler(IntentHandler):
if not states: if not states:
# No states matched constraints # No states matched constraints
raise NoStatesMatchedError( raise NoStatesMatchedError(
name=name, name=entity_name or entity_id,
area=area_name, area=area_name or area_id,
domains=domains, domains=domains,
device_classes=device_classes, device_classes=device_classes,
) )

View File

@ -26,7 +26,7 @@ ha-av==10.1.1
ha-ffmpeg==3.1.0 ha-ffmpeg==3.1.0
habluetooth==2.4.0 habluetooth==2.4.0
hass-nabucasa==0.76.0 hass-nabucasa==0.76.0
hassil==1.6.0 hassil==1.6.1
home-assistant-bluetooth==1.12.0 home-assistant-bluetooth==1.12.0
home-assistant-frontend==20240202.0 home-assistant-frontend==20240202.0
home-assistant-intents==2024.2.2 home-assistant-intents==2024.2.2

View File

@ -1025,7 +1025,7 @@ hass-nabucasa==0.76.0
hass-splunk==0.1.1 hass-splunk==0.1.1
# homeassistant.components.conversation # homeassistant.components.conversation
hassil==1.6.0 hassil==1.6.1
# homeassistant.components.jewish_calendar # homeassistant.components.jewish_calendar
hdate==0.10.4 hdate==0.10.4

View File

@ -830,7 +830,7 @@ habluetooth==2.4.0
hass-nabucasa==0.76.0 hass-nabucasa==0.76.0
# homeassistant.components.conversation # homeassistant.components.conversation
hassil==1.6.0 hassil==1.6.1
# homeassistant.components.jewish_calendar # homeassistant.components.jewish_calendar
hdate==0.10.4 hdate==0.10.4

View File

@ -1397,7 +1397,7 @@
'name': dict({ 'name': dict({
'name': 'name', 'name': 'name',
'text': 'my cool light', 'text': 'my cool light',
'value': 'my cool light', 'value': 'light.kitchen',
}), }),
}), }),
'intent': dict({ 'intent': dict({
@ -1422,7 +1422,7 @@
'name': dict({ 'name': dict({
'name': 'name', 'name': 'name',
'text': 'my cool light', 'text': 'my cool light',
'value': 'my cool light', 'value': 'light.kitchen',
}), }),
}), }),
'intent': dict({ 'intent': dict({
@ -1498,7 +1498,7 @@
'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in <area>]', 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in <area>]',
'slots': dict({ 'slots': dict({
'area': 'kitchen', 'area': 'kitchen',
'domain': 'light', 'domain': 'lights',
'state': 'on', 'state': 'on',
}), }),
'source': 'builtin', 'source': 'builtin',
@ -1572,7 +1572,7 @@
'name': dict({ 'name': dict({
'name': 'name', 'name': 'name',
'text': 'test light', 'text': 'test light',
'value': 'test light', 'value': 'light.demo_1234',
}), }),
}), }),
'intent': dict({ 'intent': dict({
@ -1581,7 +1581,7 @@
'match': True, 'match': True,
'sentence_template': '[<numeric_value_set>] <name> brightness [to] <brightness>', 'sentence_template': '[<numeric_value_set>] <name> brightness [to] <brightness>',
'slots': dict({ 'slots': dict({
'brightness': 100, 'brightness': '100%',
'name': 'test light', 'name': 'test light',
}), }),
'source': 'builtin', 'source': 'builtin',
@ -1604,7 +1604,7 @@
'name': dict({ 'name': dict({
'name': 'name', 'name': 'name',
'text': 'test light', 'text': 'test light',
'value': 'test light', 'value': 'light.demo_1234',
}), }),
}), }),
'intent': dict({ 'intent': dict({

View File

@ -1,4 +1,5 @@
"""Test for the default agent.""" """Test for the default agent."""
from collections import defaultdict from collections import defaultdict
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -85,8 +86,10 @@ async def test_exposed_areas(
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test that all areas are exposed.""" """Test that all areas are exposed."""
area_kitchen = area_registry.async_get_or_create("kitchen") area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_bedroom = area_registry.async_get_or_create("bedroom") area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
area_bedroom = area_registry.async_get_or_create("bedroom_id")
area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom")
entry = MockConfigEntry() entry = MockConfigEntry()
entry.add_to_hass(hass) entry.add_to_hass(hass)
@ -122,6 +125,9 @@ async def test_exposed_areas(
# All is well for the exposed kitchen light # All is well for the exposed kitchen light
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None
assert result.response.intent.slots["area"]["value"] == area_kitchen.id
assert result.response.intent.slots["area"]["text"] == area_kitchen.normalized_name
# Bedroom has no exposed entities # Bedroom has no exposed entities
result = await conversation.async_converse( result = await conversation.async_converse(
@ -195,7 +201,8 @@ async def test_unexposed_entities_skipped(
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test that unexposed entities are skipped in exposed areas.""" """Test that unexposed entities are skipped in exposed areas."""
area_kitchen = area_registry.async_get_or_create("kitchen") area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
# Both lights are in the kitchen # Both lights are in the kitchen
exposed_light = entity_registry.async_get_or_create("light", "demo", "1234") exposed_light = entity_registry.async_get_or_create("light", "demo", "1234")
@ -224,6 +231,9 @@ async def test_unexposed_entities_skipped(
assert len(calls) == 1 assert len(calls) == 1
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None
assert result.response.intent.slots["area"]["value"] == area_kitchen.id
assert result.response.intent.slots["area"]["text"] == area_kitchen.normalized_name
# Only one light should be returned # Only one light should be returned
hass.states.async_set(exposed_light.entity_id, "on") hass.states.async_set(exposed_light.entity_id, "on")
@ -314,8 +324,10 @@ async def test_device_area_context(
turn_on_calls = async_mock_service(hass, "light", "turn_on") turn_on_calls = async_mock_service(hass, "light", "turn_on")
turn_off_calls = async_mock_service(hass, "light", "turn_off") turn_off_calls = async_mock_service(hass, "light", "turn_off")
area_kitchen = area_registry.async_get_or_create("Kitchen") area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_bedroom = area_registry.async_get_or_create("Bedroom") area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
area_bedroom = area_registry.async_get_or_create("bedroom_id")
area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom")
# Create 2 lights in each area # Create 2 lights in each area
area_lights = defaultdict(list) area_lights = defaultdict(list)
@ -363,13 +375,14 @@ async def test_device_area_context(
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None assert result.response.intent is not None
assert result.response.intent.slots["area"]["value"] == area_kitchen.id assert result.response.intent.slots["area"]["value"] == area_kitchen.id
assert result.response.intent.slots["area"]["text"] == area_kitchen.normalized_name
# Verify only kitchen lights were targeted # Verify only kitchen lights were targeted
assert {s.entity_id for s in result.response.matched_states} == { assert {s.entity_id for s in result.response.matched_states} == {
e.entity_id for e in area_lights["kitchen"] e.entity_id for e in area_lights[area_kitchen.id]
} }
assert {c.data["entity_id"][0] for c in turn_on_calls} == { assert {c.data["entity_id"][0] for c in turn_on_calls} == {
e.entity_id for e in area_lights["kitchen"] e.entity_id for e in area_lights[area_kitchen.id]
} }
turn_on_calls.clear() turn_on_calls.clear()
@ -386,13 +399,14 @@ async def test_device_area_context(
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None assert result.response.intent is not None
assert result.response.intent.slots["area"]["value"] == area_bedroom.id assert result.response.intent.slots["area"]["value"] == area_bedroom.id
assert result.response.intent.slots["area"]["text"] == area_bedroom.normalized_name
# Verify only bedroom lights were targeted # Verify only bedroom lights were targeted
assert {s.entity_id for s in result.response.matched_states} == { assert {s.entity_id for s in result.response.matched_states} == {
e.entity_id for e in area_lights["bedroom"] e.entity_id for e in area_lights[area_bedroom.id]
} }
assert {c.data["entity_id"][0] for c in turn_on_calls} == { assert {c.data["entity_id"][0] for c in turn_on_calls} == {
e.entity_id for e in area_lights["bedroom"] e.entity_id for e in area_lights[area_bedroom.id]
} }
turn_on_calls.clear() turn_on_calls.clear()
@ -409,13 +423,14 @@ async def test_device_area_context(
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None assert result.response.intent is not None
assert result.response.intent.slots["area"]["value"] == area_bedroom.id assert result.response.intent.slots["area"]["value"] == area_bedroom.id
assert result.response.intent.slots["area"]["text"] == area_bedroom.normalized_name
# Verify only bedroom lights were targeted # Verify only bedroom lights were targeted
assert {s.entity_id for s in result.response.matched_states} == { assert {s.entity_id for s in result.response.matched_states} == {
e.entity_id for e in area_lights["bedroom"] e.entity_id for e in area_lights[area_bedroom.id]
} }
assert {c.data["entity_id"][0] for c in turn_off_calls} == { assert {c.data["entity_id"][0] for c in turn_off_calls} == {
e.entity_id for e in area_lights["bedroom"] e.entity_id for e in area_lights[area_bedroom.id]
} }
turn_off_calls.clear() turn_off_calls.clear()
@ -463,7 +478,8 @@ async def test_error_no_device_in_area(
hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry
) -> None: ) -> None:
"""Test error message when area is missing a device/entity.""" """Test error message when area is missing a device/entity."""
area_registry.async_get_or_create("kitchen") area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "turn on missing entity in the kitchen", None, Context(), None hass, "turn on missing entity in the kitchen", None, Context(), None
) )
@ -482,7 +498,7 @@ async def test_error_no_domain(
"""Test error message when no devices/entities exist for a domain.""" """Test error message when no devices/entities exist for a domain."""
# We don't have a sentence for turning on all fans # We don't have a sentence for turning on all fans
fan_domain = MatchEntity(name="domain", value="fan", text="") fan_domain = MatchEntity(name="domain", value="fan", text="fans")
recognize_result = RecognizeResult( recognize_result = RecognizeResult(
intent=Intent("HassTurnOn"), intent=Intent("HassTurnOn"),
intent_data=IntentData([]), intent_data=IntentData([]),
@ -513,7 +529,8 @@ async def test_error_no_domain_in_area(
hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry
) -> None: ) -> None:
"""Test error message when no devices/entities for a domain exist in an area.""" """Test error message when no devices/entities for a domain exist in an area."""
area_registry.async_get_or_create("kitchen") area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "turn on the lights in the kitchen", None, Context(), None hass, "turn on the lights in the kitchen", None, Context(), None
) )
@ -526,13 +543,11 @@ async def test_error_no_domain_in_area(
) )
async def test_error_no_device_class( async def test_error_no_device_class(hass: HomeAssistant, init_components) -> None:
hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry
) -> None:
"""Test error message when no entities of a device class exist.""" """Test error message when no entities of a device class exist."""
# We don't have a sentence for opening all windows # We don't have a sentence for opening all windows
window_class = MatchEntity(name="device_class", value="window", text="") window_class = MatchEntity(name="device_class", value="window", text="windows")
recognize_result = RecognizeResult( recognize_result = RecognizeResult(
intent=Intent("HassTurnOn"), intent=Intent("HassTurnOn"),
intent_data=IntentData([]), intent_data=IntentData([]),
@ -563,7 +578,8 @@ async def test_error_no_device_class_in_area(
hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry
) -> None: ) -> None:
"""Test error message when no entities of a device class exist in an area.""" """Test error message when no entities of a device class exist in an area."""
area_registry.async_get_or_create("bedroom") area_bedroom = area_registry.async_get_or_create("bedroom_id")
area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom")
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "open bedroom windows", None, Context(), None hass, "open bedroom windows", None, Context(), None
) )
@ -600,7 +616,8 @@ async def test_no_states_matched_default_error(
hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry
) -> None: ) -> None:
"""Test default response when no states match and slots are missing.""" """Test default response when no states match and slots are missing."""
area_registry.async_get_or_create("kitchen") area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
with patch( with patch(
"homeassistant.components.conversation.default_agent.intent.async_handle", "homeassistant.components.conversation.default_agent.intent.async_handle",
@ -629,9 +646,9 @@ async def test_empty_aliases(
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
) -> None: ) -> None:
"""Test that empty aliases are not added to slot lists.""" """Test that empty aliases are not added to slot lists."""
area_kitchen = area_registry.async_get_or_create("kitchen") area_kitchen = area_registry.async_get_or_create("kitchen_id")
assert area_kitchen.id is not None area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
area_registry.async_update(area_kitchen.id, aliases={" "}) area_kitchen = area_registry.async_update(area_kitchen.id, aliases={" "})
entry = MockConfigEntry() entry = MockConfigEntry()
entry.add_to_hass(hass) entry.add_to_hass(hass)
@ -643,11 +660,16 @@ async def test_empty_aliases(
device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id)
kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234")
entity_registry.async_update_entity( kitchen_light = entity_registry.async_update_entity(
kitchen_light.entity_id, device_id=kitchen_device.id, aliases={" "} kitchen_light.entity_id,
device_id=kitchen_device.id,
name="kitchen light",
aliases={" "},
) )
hass.states.async_set( hass.states.async_set(
kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} kitchen_light.entity_id,
"on",
attributes={ATTR_FRIENDLY_NAME: kitchen_light.name},
) )
with patch( with patch(
@ -665,16 +687,16 @@ async def test_empty_aliases(
assert slot_lists.keys() == {"area", "name"} assert slot_lists.keys() == {"area", "name"}
areas = slot_lists["area"] areas = slot_lists["area"]
assert len(areas.values) == 1 assert len(areas.values) == 1
assert areas.values[0].value_out == "kitchen" assert areas.values[0].value_out == area_kitchen.id
assert areas.values[0].text_in.text == area_kitchen.normalized_name
names = slot_lists["name"] names = slot_lists["name"]
assert len(names.values) == 1 assert len(names.values) == 1
assert names.values[0].value_out == "kitchen light" assert names.values[0].value_out == kitchen_light.entity_id
assert names.values[0].text_in.text == kitchen_light.name
async def test_all_domains_loaded( async def test_all_domains_loaded(hass: HomeAssistant, init_components) -> None:
hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry
) -> None:
"""Test that sentences for all domains are always loaded.""" """Test that sentences for all domains are always loaded."""
# light domain is not loaded # light domain is not loaded