Remove the name trie from the default conversation agent (#181776)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hansen
2026-09-09 20:35:53 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 2edb623a71
commit ddfbb0c790
2 changed files with 32 additions and 117 deletions
@@ -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."""