mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Remove hassil fuzzy matcher (#170653)
This commit is contained in:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user