Remove hassil fuzzy matcher (#170653)

This commit is contained in:
Michael Hansen
2026-05-14 11:00:02 -04:00
committed by GitHub
parent febfd409d3
commit e66b24b0fc
3 changed files with 2 additions and 353 deletions
@@ -11,18 +11,13 @@ import time
from typing import IO, Any, cast
from hassil.expression import Expression, Group, ListReference, TextChunk
from hassil.fuzzy import FuzzyNgramMatcher, SlotCombinationInfo
from hassil.intents import (
Intent,
IntentData,
Intents,
SlotList,
TextSlotList,
TextSlotValue,
WildcardSlotList,
)
from hassil.models import MatchEntity
from hassil.ngram import Sqlite3NgramModel
from hassil.recognize import (
MISSING_ENTITY,
RecognizeResult,
@@ -34,11 +29,7 @@ from hassil.trie import Trie
from hassil.util import merge_dict, remove_punctuation
from home_assistant_intents import (
ErrorKey,
FuzzyConfig,
FuzzyLanguageResponses,
LanguageScores,
get_fuzzy_config,
get_fuzzy_language,
get_intents,
get_language_scores,
get_languages,
@@ -95,7 +86,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
ERROR_SENTINEL = object()
@@ -114,8 +104,6 @@ class LanguageIntents:
intent_responses: dict[str, Any]
error_responses: dict[str, Any]
language_variant: str | None
fuzzy_matcher: FuzzyNgramMatcher | None = None
fuzzy_responses: FuzzyLanguageResponses | None = None
@dataclass(slots=True)
@@ -133,9 +121,6 @@ class IntentMatchingStage(Enum):
EXPOSED_ENTITIES_ONLY = auto()
"""Match against exposed entities only."""
FUZZY = auto()
"""Use fuzzy matching to guess intent."""
UNEXPOSED_ENTITIES = auto()
"""Match against unexposed entities in Home Assistant."""
@@ -259,10 +244,6 @@ class DefaultAgent(ConversationEntity):
# LRU cache to avoid unnecessary intent matching
self._intent_cache = IntentCache(capacity=128)
# Shared configuration for fuzzy matching
self.fuzzy_matching = True
self._fuzzy_config: FuzzyConfig | None = None
async def async_added_to_hass(self) -> None:
"""Subscribe to intents updates when added to hass."""
self._unsub_intents = get_agent_manager(self.hass).subscribe_intents(
@@ -422,8 +403,6 @@ class DefaultAgent(ConversationEntity):
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(intent_result),
# True if match was not exact
"fuzzy_match": False,
}
if successful_match:
@@ -447,10 +426,6 @@ class DefaultAgent(ConversationEntity):
else:
result_dict["source"] = "builtin"
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
METADATA_FUZZY_MATCH, False
)
return result_dict
async def _async_handle_message(
@@ -704,39 +679,9 @@ class DefaultAgent(ConversationEntity):
return strict_result
if strict_intents_only:
# Don't try matching against all entities or doing a fuzzy match
# Don't try matching against all entities
return None
# Use fuzzy matching
skip_fuzzy_match = False
if cache_value is not None:
if (cache_value.result is not None) and (
cache_value.stage == IntentMatchingStage.FUZZY
):
_LOGGER.debug("Got cached result for fuzzy match")
return cache_value.result
# Continue with matching, but we know we won't succeed for fuzzy
# match.
skip_fuzzy_match = True
if (not skip_fuzzy_match) and self.fuzzy_matching:
start_time = time.monotonic()
fuzzy_result = self._recognize_fuzzy(lang_intents, user_input)
# Update cache
self._intent_cache.put(
cache_key,
IntentCacheValue(result=fuzzy_result, stage=IntentMatchingStage.FUZZY),
)
_LOGGER.debug(
"Did fuzzy match in %s second(s)", time.monotonic() - start_time
)
if fuzzy_result is not None:
return fuzzy_result
# Try again with all entities (including unexposed)
skip_unexposed_entities_match = False
if cache_value is not None:
@@ -814,56 +759,6 @@ class DefaultAgent(ConversationEntity):
return maybe_result
def _recognize_fuzzy(
self, lang_intents: LanguageIntents, user_input: ConversationInput
) -> RecognizeResult | None:
"""Return fuzzy recognition from hassil."""
if lang_intents.fuzzy_matcher is None:
return None
context_area: str | None = None
satellite_area, _ = self._get_satellite_area_and_device(
user_input.satellite_id, user_input.device_id
)
if satellite_area:
context_area = satellite_area.name
fuzzy_result = lang_intents.fuzzy_matcher.match(
user_input.text, context_area=context_area
)
if fuzzy_result is None:
return None
response = "default"
if lang_intents.fuzzy_responses:
domain = "" # no domain
if "name" in fuzzy_result.slots:
domain = fuzzy_result.name_domain
elif "domain" in fuzzy_result.slots:
domain = fuzzy_result.slots["domain"].value
slot_combo = tuple(sorted(fuzzy_result.slots))
if (
intent_responses := lang_intents.fuzzy_responses.get(
fuzzy_result.intent_name
)
) and (combo_responses := intent_responses.get(slot_combo)):
response = combo_responses.get(domain, response)
entities = [
MatchEntity(name=slot_name, value=slot_value.value, text=slot_value.text)
for slot_name, slot_value in fuzzy_result.slots.items()
]
return RecognizeResult(
intent=Intent(name=fuzzy_result.intent_name),
intent_data=IntentData(sentence_texts=[]),
intent_metadata={METADATA_FUZZY_MATCH: True},
entities={entity.name: entity for entity in entities},
entities_list=entities,
response=response,
)
def _recognize_unknown_names(
self,
lang_intents: LanguageIntents,
@@ -1220,88 +1115,12 @@ class DefaultAgent(ConversationEntity):
intent_responses = responses_dict.get("intents", {})
error_responses = responses_dict.get("errors", {})
if not self.fuzzy_matching:
_LOGGER.debug("Fuzzy matching is disabled")
return LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
language_variant,
)
# Load fuzzy
fuzzy_info = get_fuzzy_language(language_variant, json_load=json_load)
if fuzzy_info is None:
_LOGGER.debug(
"Fuzzy matching not available for language: %s", language_variant
)
return LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
language_variant,
)
if self._fuzzy_config is None:
# Load shared config
self._fuzzy_config = get_fuzzy_config(json_load=json_load)
_LOGGER.debug("Loaded shared fuzzy matching config")
assert self._fuzzy_config is not None
fuzzy_matcher: FuzzyNgramMatcher | None = None
fuzzy_responses: FuzzyLanguageResponses | None = None
start_time = time.monotonic()
fuzzy_responses = fuzzy_info.responses
fuzzy_matcher = FuzzyNgramMatcher(
intents=intents,
intent_models={
intent_name: Sqlite3NgramModel(
order=fuzzy_model.order,
words={
word: str(word_id)
for word, word_id in fuzzy_model.words.items()
},
database_path=fuzzy_model.database_path,
)
for intent_name, fuzzy_model in fuzzy_info.ngram_models.items()
},
intent_slot_list_names=self._fuzzy_config.slot_list_names,
slot_combinations={
intent_name: {
combo_key: SlotCombinationInfo(
context_area=combo_info.context_area,
name_domains=(
set(combo_info.name_domains)
if combo_info.name_domains
else None
),
)
for combo_key, combo_info in intent_combos.items()
}
for intent_name, intent_combos in self._fuzzy_config.slot_combinations.items()
},
domain_keywords=fuzzy_info.domain_keywords,
stop_words=fuzzy_info.stop_words,
)
_LOGGER.debug(
"Loaded fuzzy matcher in %s second(s): language=%s, intents=%s",
time.monotonic() - start_time,
language_variant,
sorted(fuzzy_matcher.intent_models.keys()),
)
return LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
language_variant,
fuzzy_matcher=fuzzy_matcher,
fuzzy_responses=fuzzy_responses,
)
@callback
@@ -1382,10 +1201,6 @@ class DefaultAgent(ConversationEntity):
"floor": TextSlotList.from_tuples(floor_names, allow_template=False),
}
# Reload fuzzy matchers with new slot lists
if self.fuzzy_matching:
await self.hass.async_add_executor_job(self._load_fuzzy_matchers)
self._listen_clear_slot_list()
_LOGGER.debug(
@@ -1395,25 +1210,6 @@ class DefaultAgent(ConversationEntity):
return self._slot_lists
def _load_fuzzy_matchers(self) -> None:
"""Reload fuzzy matchers for all loaded languages."""
for lang_intents in self._lang_intents.values():
if (not isinstance(lang_intents, LanguageIntents)) or (
lang_intents.fuzzy_matcher is None
):
continue
lang_matcher = lang_intents.fuzzy_matcher
lang_intents.fuzzy_matcher = FuzzyNgramMatcher(
intents=lang_matcher.intents,
intent_models=lang_matcher.intent_models,
intent_slot_list_names=lang_matcher.intent_slot_list_names,
slot_combinations=lang_matcher.slot_combinations,
domain_keywords=lang_matcher.domain_keywords,
stop_words=lang_matcher.stop_words,
slot_lists=self._slot_lists,
)
def _make_intent_context(
self, user_input: ConversationInput
) -> dict[str, Any] | None:
@@ -463,7 +463,6 @@
'value': 'my cool light',
}),
}),
'fuzzy_match': False,
'intent': dict({
'name': 'HassTurnOn',
}),
@@ -488,7 +487,6 @@
'value': 'my cool light',
}),
}),
'fuzzy_match': False,
'intent': dict({
'name': 'HassTurnOff',
}),
@@ -518,7 +516,6 @@
'value': 'light',
}),
}),
'fuzzy_match': False,
'intent': dict({
'name': 'HassTurnOn',
}),
@@ -554,7 +551,6 @@
'value': 'on',
}),
}),
'fuzzy_match': False,
'intent': dict({
'name': 'HassGetState',
}),
@@ -589,7 +585,6 @@
}),
}),
'file': 'en/beer.yaml',
'fuzzy_match': False,
'intent': dict({
'name': 'OrderBeer',
}),
@@ -630,7 +625,6 @@
'value': 'test light',
}),
}),
'fuzzy_match': False,
'intent': dict({
'name': 'HassLightSet',
}),
@@ -662,7 +656,6 @@
'value': 'test light',
}),
}),
'fuzzy_match': False,
'intent': dict({
'name': 'HassLightSet',
}),
@@ -33,12 +33,7 @@ from homeassistant.components.intent import (
TimerInfo,
async_register_timer_handler,
)
from homeassistant.components.light import (
ATTR_SUPPORTED_COLOR_MODES,
DOMAIN as LIGHT_DOMAIN,
ColorMode,
intent as light_intent,
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME,
@@ -95,11 +90,6 @@ async def init_components(hass: HomeAssistant) -> None:
assert await async_setup_component(hass, "conversation", {})
assert await async_setup_component(hass, "intent", {})
# Disable fuzzy matching by default for tests
agent = async_get_agent(hass)
assert isinstance(agent, default_agent.DefaultAgent)
agent.fuzzy_matching = False
@pytest.mark.parametrize(
"er_kwargs",
@@ -2972,35 +2962,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_intent_cache_fuzzy(hass: HomeAssistant) -> None:
"""Test that intent recognition results are cached for fuzzy matches."""
agent = async_get_agent(hass)
# There is no entity named test light
user_input = ConversationInput(
text="turn on test light",
context=Context(),
conversation_id=None,
device_id=None,
satellite_id=None,
language=hass.config.language,
agent_id=None,
)
result = await agent.async_recognize_intent(user_input)
assert result is not None
assert result.unmatched_entities["area"].text == "test "
# Mark this result so we know it is from cache next time
mark = "_from_cache"
setattr(result, mark, True)
# Should be from cache this time
result = await agent.async_recognize_intent(user_input)
assert result is not None
assert getattr(result, mark, None) is True
@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."""
@@ -3420,107 +3381,6 @@ async def test_language_with_alternative_code(
assert call.data == {"entity_id": [entity_id]}
@pytest.mark.parametrize("fuzzy_matching", [True, False])
@pytest.mark.parametrize(
("sentence", "intent_type", "slots"),
[
("time", "HassGetCurrentTime", {}),
("how about my timers", "HassTimerStatus", {}),
(
"the office needs more blue",
"HassLightSet",
{"area": "office", "color": "blue"},
),
(
"50% office light",
"HassLightSet",
{"name": "office light", "brightness": "50%"},
),
(
"turn on the lights in the spaceship",
"HassTurnOn",
{"domain": "lights", "area": "office"}, # context area
),
],
)
async def test_fuzzy_matching(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
fuzzy_matching: bool,
sentence: str,
intent_type: str,
slots: dict[str, Any],
) -> None:
"""Test fuzzy vs. non-fuzzy matching on some English sentences."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "conversation", {})
assert await async_setup_component(hass, "intent", {})
await light_intent.async_setup_intents(hass)
agent = async_get_agent(hass)
agent.fuzzy_matching = fuzzy_matching
area_office = area_registry.async_get_or_create("office_id")
area_office = area_registry.async_update(area_office.id, name="office")
entry = MockConfigEntry()
entry.add_to_hass(hass)
office_satellite = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections=set(),
identifiers={("demo", "id-1234")},
)
device_registry.async_update_device(office_satellite.id, area_id=area_office.id)
office_light = entity_registry.async_get_or_create(
"light", "demo", "1234", original_name="office light"
)
office_light = entity_registry.async_update_entity(
office_light.entity_id, area_id=area_office.id
)
hass.states.async_set(
office_light.entity_id,
"on",
attributes={
ATTR_FRIENDLY_NAME: "office light",
ATTR_SUPPORTED_COLOR_MODES: [ColorMode.BRIGHTNESS, ColorMode.RGB],
},
)
_on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
result = await conversation.async_converse(
hass,
sentence,
None,
Context(),
language="en",
device_id=office_satellite.id,
)
response = result.response
if not fuzzy_matching:
# Should not match
assert response.response_type == intent.IntentResponseType.ERROR
return
assert response.response_type in (
intent.IntentResponseType.ACTION_DONE,
intent.IntentResponseType.QUERY_ANSWER,
)
assert response.intent is not None
assert response.intent.intent_type == intent_type
# Verify slot texts match
actual_slots = {
slot_name: slot_value["text"]
for slot_name, slot_value in response.intent.slots.items()
if slot_name != "preferred_area_id" # context area
}
assert actual_slots == slots
@pytest.mark.usefixtures("init_components")
async def test_intent_tool_call_in_chat_log(hass: HomeAssistant) -> None:
"""Test that intent tool calls are stored in the chat log."""