From ddfbb0c7908b04c5ef4231399a5d98dbd4560ce5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 9 Sep 2026 13:35:53 -0500 Subject: [PATCH] Remove the name trie from the default conversation agent (#181776) Co-authored-by: Claude Opus 5 --- .../components/conversation/default_agent.py | 63 +++++--------- .../conversation/test_default_agent.py | 86 +++---------------- 2 files changed, 32 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 63970bef3a9b..c3c4e34ec32b 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -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), diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index e4dd8c21ce7e..54732b164959 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -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."""