diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 99464d64ef7..61329cf6733 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import Literal +from typing import Any, Literal from hassil.recognize import RecognizeResult import voluptuous as vol @@ -21,6 +21,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -52,6 +53,8 @@ from .const import ( DATA_COMPONENT, DOMAIN, HOME_ASSISTANT_AGENT, + METADATA_CUSTOM_FILE, + METADATA_CUSTOM_SENTENCE, SERVICE_PROCESS, SERVICE_RELOAD, ConversationEntityFeature, @@ -266,10 +269,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) hass.data[DATA_COMPONENT] = entity_component - agent_config = config.get(DOMAIN, {}) - await async_setup_default_agent( - hass, entity_component, config_intents=agent_config.get("intents", {}) - ) + manager = get_agent_manager(hass) + + hass_config_path = hass.config.path() + config_intents = _get_config_intents(config, hass_config_path) + manager.update_config_intents(config_intents) + + await async_setup_default_agent(hass, entity_component) async def handle_process(service: ServiceCall) -> ServiceResponse: """Parse text into commands.""" @@ -294,9 +300,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def handle_reload(service: ServiceCall) -> None: """Reload intents.""" - agent = get_agent_manager(hass).default_agent + language = service.data.get(ATTR_LANGUAGE) + if language is None: + conf = await async_integration_yaml_config(hass, DOMAIN) + if conf is not None: + config_intents = _get_config_intents(conf, hass_config_path) + manager.update_config_intents(config_intents) + + agent = manager.default_agent if agent is not None: - await agent.async_reload(language=service.data.get(ATTR_LANGUAGE)) + await agent.async_reload(language=language) hass.services.async_register( DOMAIN, @@ -313,6 +326,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str, Any]: + """Return config intents.""" + intents = config.get(DOMAIN, {}).get("intents", {}) + return { + "intents": { + intent_name: { + "data": [ + { + "sentences": sentences, + "metadata": { + METADATA_CUSTOM_SENTENCE: True, + METADATA_CUSTOM_FILE: hass_config_path, + }, + } + ] + } + for intent_name, sentences in intents.items() + } + } + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" return await hass.data[DATA_COMPONENT].async_setup_entry(entry) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index bef6d933abe..712a8b28fab 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -147,6 +147,7 @@ class AgentManager: self.hass = hass self._agents: dict[str, AbstractConversationAgent] = {} self.default_agent: DefaultAgent | None = None + self.config_intents: dict[str, Any] = {} self.triggers_details: list[TriggerDetails] = [] @callback @@ -199,9 +200,16 @@ class AgentManager: async def async_setup_default_agent(self, agent: DefaultAgent) -> None: """Set up the default agent.""" + agent.update_config_intents(self.config_intents) agent.update_triggers(self.triggers_details) self.default_agent = agent + def update_config_intents(self, intents: dict[str, Any]) -> None: + """Update config intents.""" + self.config_intents = intents + if self.default_agent is not None: + self.default_agent.update_config_intents(intents) + def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE: """Register a trigger.""" self.triggers_details.append(trigger_details) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index e1029de9918..8f6404bdc00 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -30,3 +30,7 @@ class ConversationEntityFeature(IntFlag): """Supported features of the conversation entity.""" CONTROL = 1 + + +METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" +METADATA_CUSTOM_FILE = "hass_custom_file" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 9ed85afc7c4..5a516f068a4 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -77,7 +77,12 @@ from homeassistant.util.json import JsonObjectType, json_loads_object from .agent_manager import get_agent_manager from .chat_log import AssistantContent, ChatLog -from .const import DOMAIN, ConversationEntityFeature +from .const import ( + DOMAIN, + METADATA_CUSTOM_FILE, + METADATA_CUSTOM_SENTENCE, + ConversationEntityFeature, +) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult from .trace import ConversationTraceEventType, async_conversation_trace_append @@ -91,8 +96,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] _DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} -METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" -METADATA_CUSTOM_FILE = "hass_custom_file" METADATA_FUZZY_MATCH = "hass_fuzzy_match" ERROR_SENTINEL = object() @@ -202,10 +205,9 @@ class IntentCache: async def async_setup_default_agent( hass: HomeAssistant, entity_component: EntityComponent[ConversationEntity], - config_intents: dict[str, Any], ) -> None: """Set up entity registry listener for the default agent.""" - agent = DefaultAgent(hass, config_intents) + agent = DefaultAgent(hass) await entity_component.async_add_entities([agent]) await get_agent_manager(hass).async_setup_default_agent(agent) @@ -230,14 +232,14 @@ class DefaultAgent(ConversationEntity): _attr_name = "Home Assistant" _attr_supported_features = ConversationEntityFeature.CONTROL - def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the default agent.""" self.hass = hass self._lang_intents: dict[str, LanguageIntents | object] = {} self._load_intents_lock = asyncio.Lock() - # intent -> [sentences] - self._config_intents: dict[str, Any] = config_intents + # Intents from common conversation config + self._config_intents: dict[str, Any] = {} # Sentences that will trigger a callback (skipping intent recognition) self._triggers_details: list[TriggerDetails] = [] @@ -1035,6 +1037,14 @@ class DefaultAgent(ConversationEntity): # Intents have changed, so we must clear the cache self._intent_cache.clear() + @callback + def update_config_intents(self, intents: dict[str, Any]) -> None: + """Update config intents.""" + self._config_intents = intents + + # Intents have changed, so we must clear the cache + self._intent_cache.clear() + async def async_prepare(self, language: str | None = None) -> None: """Load intents for a language.""" if language is None: @@ -1159,33 +1169,10 @@ class DefaultAgent(ConversationEntity): custom_sentences_path, ) - # Load sentences from HA config for default language only - if self._config_intents and ( - self.hass.config.language in (language, language_variant) - ): - hass_config_path = self.hass.config.path() - merge_dict( - intents_dict, - { - "intents": { - intent_name: { - "data": [ - { - "sentences": sentences, - "metadata": { - METADATA_CUSTOM_SENTENCE: True, - METADATA_CUSTOM_FILE: hass_config_path, - }, - } - ] - } - for intent_name, sentences in self._config_intents.items() - } - }, - ) - _LOGGER.debug( - "Loaded intents from configuration.yaml", - ) + merge_dict( + intents_dict, + self._config_intents, + ) if not intents_dict: return None diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 6c1f7703287..4b376e83301 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -144,7 +144,7 @@ async def test_custom_agent( @pytest.mark.usefixtures("init_components") -async def test_prepare_reload(hass: HomeAssistant) -> None: +async def test_reload(hass: HomeAssistant) -> None: """Test calling the reload service.""" language = hass.config.language agent = async_get_agent(hass) @@ -154,20 +154,39 @@ async def test_prepare_reload(hass: HomeAssistant) -> None: # Confirm intents are loaded assert agent._lang_intents.get(language) + # Confirm config intents are empty + assert not agent._config_intents["intents"] # Try to clear for a different language - await hass.services.async_call("conversation", "reload", {"language": "elvish"}) - await hass.async_block_till_done() + await hass.services.async_call( + "conversation", "reload", {"language": "elvish"}, blocking=True + ) # Confirm intents are still loaded assert agent._lang_intents.get(language) + # Confirm config intents are still empty + assert not agent._config_intents["intents"] - # Clear cache for all languages - await hass.services.async_call("conversation", "reload", {}) - await hass.async_block_till_done() + # Reload from a changed configuration file + hass_config_new = { + "conversation": { + "intents": { + "TestIntent": [ + "Test intent phrase", + "Another test intent phrase", + ] + } + } + } + with patch( + "homeassistant.config.load_yaml_config_file", return_value=hass_config_new + ): + await hass.services.async_call("conversation", "reload", {}, blocking=True) # Confirm intent cache is cleared assert not agent._lang_intents.get(language) + # Confirm new config intents are loaded + assert agent._config_intents["intents"] @pytest.mark.usefixtures("init_components")