mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Remove the name trie from the default conversation agent (#181776)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2edb623a71
commit
ddfbb0c790
@@ -11,7 +11,7 @@ import time
|
||||
from typing import IO, Any, cast, override
|
||||
|
||||
from gazetteer_matcher import FrameCandidate, GazetteerMatcher
|
||||
from hassil.expression import Expression, Group, ListReference, TextChunk
|
||||
from hassil.expression import Expression, Group, ListReference
|
||||
from hassil.intents import (
|
||||
Intents,
|
||||
SlotList,
|
||||
@@ -26,7 +26,6 @@ from hassil.recognize import (
|
||||
recognize_best,
|
||||
)
|
||||
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
|
||||
from hassil.trie import Trie
|
||||
from hassil.util import merge_dict, remove_punctuation
|
||||
from home_assistant_intents import (
|
||||
ErrorKey,
|
||||
@@ -257,10 +256,7 @@ class DefaultAgent(ConversationEntity):
|
||||
# Slot lists for entities, areas, etc.
|
||||
self._slot_lists: dict[str, SlotList] | None = None
|
||||
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
|
||||
|
||||
# Used to filter slot lists before intent matching
|
||||
self._exposed_names_trie: Trie | None = None
|
||||
self._unexposed_names_trie: Trie | None = None
|
||||
self._unexposed_names_list: TextSlotList | None = None
|
||||
|
||||
# LRU cache to avoid unnecessary intent matching
|
||||
self._intent_cache = IntentCache(capacity=128)
|
||||
@@ -373,14 +369,6 @@ class DefaultAgent(ConversationEntity):
|
||||
slot_lists = await self._make_slot_lists()
|
||||
intent_context = self._make_intent_context(user_input)
|
||||
|
||||
if self._exposed_names_trie is not None:
|
||||
# Filter by input string
|
||||
text = remove_punctuation(user_input.text).strip().lower()
|
||||
slot_lists["name"] = TextSlotList(
|
||||
name="name",
|
||||
values=[result[2] for result in self._exposed_names_trie.find(text)],
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
|
||||
result = await self.hass.async_add_executor_job(
|
||||
@@ -895,7 +883,7 @@ class DefaultAgent(ConversationEntity):
|
||||
if not skip_unexposed_entities_match:
|
||||
unexposed_entities_slot_lists = {
|
||||
**slot_lists,
|
||||
"name": self._get_unexposed_entity_names(user_input.text),
|
||||
"name": self._get_unexposed_entity_names(),
|
||||
}
|
||||
|
||||
start_time = time.monotonic()
|
||||
@@ -1044,25 +1032,18 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
return maybe_result
|
||||
|
||||
def _get_unexposed_entity_names(self, text: str) -> TextSlotList:
|
||||
"""Get filtered slot list with unexposed entity names in Home Assistant."""
|
||||
if self._unexposed_names_trie is None:
|
||||
# Build trie
|
||||
self._unexposed_names_trie = Trie()
|
||||
for name_tuple in self._get_entity_name_tuples(exposed=False):
|
||||
self._unexposed_names_trie.insert(
|
||||
name_tuple[0].lower(),
|
||||
TextSlotValue.from_tuple(name_tuple, allow_template=False),
|
||||
)
|
||||
def _get_unexposed_entity_names(self) -> TextSlotList:
|
||||
"""Get slot list with unexposed entity names in Home Assistant."""
|
||||
if self._unexposed_names_list is None:
|
||||
self._unexposed_names_list = TextSlotList(
|
||||
name="name",
|
||||
values=[
|
||||
TextSlotValue.from_tuple(name_tuple, allow_template=False)
|
||||
for name_tuple in self._get_entity_name_tuples(exposed=False)
|
||||
],
|
||||
)
|
||||
|
||||
# Build filtered slot list
|
||||
text_lower = remove_punctuation(text).strip().lower()
|
||||
return TextSlotList(
|
||||
name="name",
|
||||
values=[
|
||||
result[2] for result in self._unexposed_names_trie.find(text_lower)
|
||||
],
|
||||
)
|
||||
return self._unexposed_names_list
|
||||
|
||||
def _get_entity_name_tuples(
|
||||
self, exposed: bool
|
||||
@@ -1094,7 +1075,12 @@ class DefaultAgent(ConversationEntity):
|
||||
self.hass, entity_entry, state=state
|
||||
):
|
||||
# Strip punctuation so aliases match the cleaned input text.
|
||||
yield (remove_punctuation(name).strip(), name, context)
|
||||
input_name = remove_punctuation(name).strip()
|
||||
if not input_name:
|
||||
# An empty name would match anywhere in the input.
|
||||
continue
|
||||
|
||||
yield (input_name, name, context)
|
||||
|
||||
def _recognize_strict(
|
||||
self,
|
||||
@@ -1334,8 +1320,7 @@ class DefaultAgent(ConversationEntity):
|
||||
if self._unsub_clear_slot_list is None:
|
||||
return
|
||||
self._slot_lists = None
|
||||
self._exposed_names_trie = None
|
||||
self._unexposed_names_trie = None
|
||||
self._unexposed_names_list = None
|
||||
for unsub in self._unsub_clear_slot_list:
|
||||
unsub()
|
||||
self._unsub_clear_slot_list = None
|
||||
@@ -1390,13 +1375,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
floor_names.append((remove_punctuation(alias).strip(), floor.name))
|
||||
|
||||
# Build trie
|
||||
self._exposed_names_trie = Trie()
|
||||
name_list = TextSlotList.from_tuples(exposed_entity_names, allow_template=False)
|
||||
for name_value in name_list.values:
|
||||
assert isinstance(name_value.text_in, TextChunk)
|
||||
name_text = remove_punctuation(name_value.text_in.text).strip().lower()
|
||||
self._exposed_names_trie.insert(name_text, name_value)
|
||||
|
||||
self._slot_lists = {
|
||||
"area": TextSlotList.from_tuples(area_names, allow_template=False),
|
||||
|
||||
@@ -1882,6 +1882,13 @@ async def test_no_states_matched_default_error(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"empty_alias",
|
||||
[
|
||||
pytest.param(" ", id="whitespace"),
|
||||
pytest.param("!!!", id="punctuation"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_empty_aliases(
|
||||
hass: HomeAssistant,
|
||||
@@ -1889,6 +1896,7 @@ async def test_empty_aliases(
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
empty_alias: str,
|
||||
) -> None:
|
||||
"""Test that empty aliases are not added to slot lists."""
|
||||
floor_1 = floor_registry.async_create("first floor", aliases={" "})
|
||||
@@ -1913,7 +1921,9 @@ async def test_empty_aliases(
|
||||
kitchen_light.entity_id,
|
||||
device_id=kitchen_device.id,
|
||||
name="kitchen light",
|
||||
aliases=[er.COMPUTED_NAME, " "],
|
||||
# Area and floor aliases are only guarded against whitespace, so the
|
||||
# punctuation case is exercised on the entity.
|
||||
aliases=[er.COMPUTED_NAME, empty_alias],
|
||||
)
|
||||
hass.states.async_set(
|
||||
kitchen_light.entity_id,
|
||||
@@ -3122,80 +3132,6 @@ async def test_intent_cache_all_entities(hass: HomeAssistant) -> None:
|
||||
assert getattr(result, mark, None) is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_entities_filtered_by_input(hass: HomeAssistant) -> None:
|
||||
"""Test that entities are filtered by the input text before intent matching."""
|
||||
agent = async_get_agent(hass)
|
||||
|
||||
# Only the switch is exposed
|
||||
hass.states.async_set("light.test_light", "off")
|
||||
hass.states.async_set(
|
||||
"light.test_light_2", "off", attributes={ATTR_FRIENDLY_NAME: "test light"}
|
||||
)
|
||||
hass.states.async_set("cover.garage_door", "closed")
|
||||
hass.states.async_set("switch.test_switch", "off")
|
||||
expose_entity(hass, "light.test_light", False)
|
||||
expose_entity(hass, "light.test_light_2", False)
|
||||
expose_entity(hass, "cover.garage_door", False)
|
||||
expose_entity(hass, "switch.test_switch", True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# test switch is exposed
|
||||
user_input = ConversationInput(
|
||||
text="turn on test switch",
|
||||
context=Context(),
|
||||
conversation_id=None,
|
||||
device_id=None,
|
||||
satellite_id=None,
|
||||
language=hass.config.language,
|
||||
agent_id=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.conversation.default_agent.recognize_best",
|
||||
return_value=None,
|
||||
) as recognize_best:
|
||||
await agent.async_recognize_intent(user_input)
|
||||
|
||||
# (1) exposed, (2) all entities
|
||||
assert len(recognize_best.call_args_list) == 2
|
||||
|
||||
# Only the test light should have been considered because its name shows
|
||||
# up in the input text.
|
||||
slot_lists = recognize_best.call_args_list[0].kwargs["slot_lists"]
|
||||
name_list = slot_lists["name"]
|
||||
assert len(name_list.values) == 1
|
||||
assert name_list.values[0].text_in.text == "test switch"
|
||||
|
||||
# test light is not exposed
|
||||
user_input = ConversationInput(
|
||||
text="turn on Test Light", # different casing for name
|
||||
context=Context(),
|
||||
conversation_id=None,
|
||||
device_id=None,
|
||||
satellite_id=None,
|
||||
language=hass.config.language,
|
||||
agent_id=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.conversation.default_agent.recognize_best",
|
||||
return_value=None,
|
||||
) as recognize_best:
|
||||
await agent.async_recognize_intent(user_input)
|
||||
|
||||
# (1) exposed, (2) all entities
|
||||
assert len(recognize_best.call_args_list) == 2
|
||||
|
||||
# Both test lights should have been considered because their name shows
|
||||
# up in the input text.
|
||||
slot_lists = recognize_best.call_args_list[1].kwargs["slot_lists"]
|
||||
name_list = slot_lists["name"]
|
||||
assert len(name_list.values) == 2
|
||||
assert name_list.values[0].text_in.text == "test light"
|
||||
assert name_list.values[1].text_in.text == "test light"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_entities_names_are_not_templates(hass: HomeAssistant) -> None:
|
||||
"""Test that entities names are not treated as hassil templates."""
|
||||
|
||||
Reference in New Issue
Block a user