From 736a5d4a94ed3684f3d7c05ec7e47284ee4c9d41 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 23 Oct 2024 16:45:52 -0500 Subject: [PATCH 01/12] Handle sentence triggers and registered intents in Assist LLM API --- .../components/anthropic/conversation.py | 5 + .../components/conversation/__init__.py | 28 ++- .../components/conversation/default_agent.py | 225 ++++++++++++++---- .../conversation.py | 19 +- .../components/ollama/conversation.py | 5 + .../openai_conversation/conversation.py | 5 + homeassistant/helpers/llm.py | 37 ++- .../components/anthropic/test_conversation.py | 67 ++++++ tests/components/conversation/test_init.py | 99 +++++++- .../test_conversation.py | 68 ++++++ tests/components/ollama/test_conversation.py | 70 ++++++ .../openai_conversation/test_conversation.py | 67 ++++++ 12 files changed, 643 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 20e555e9592..c2300aab9ed 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -159,6 +159,11 @@ class AnthropicConversationEntity( return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id ) + + if external_result := await llm_api.api.async_handle_externally(user_input): + # Handled externally + return external_result + tools = [ _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools ] diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 17f3b6f5ccc..898b7b2cf4f 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -44,7 +44,7 @@ from .const import ( SERVICE_RELOAD, ConversationEntityFeature, ) -from .default_agent import async_setup_default_agent +from .default_agent import DefaultAgent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult @@ -207,6 +207,32 @@ async def async_prepare_agent( await agent.async_prepare(language) +async def async_handle_sentence_triggers( + hass: HomeAssistant, user_input: ConversationInput +) -> str | None: + """Try to match input against sentence triggers and return response text. + + Returns None if no match occurred. + """ + default_agent = async_get_agent(hass) + assert isinstance(default_agent, DefaultAgent) + + return await default_agent.async_handle_sentence_triggers(user_input) + + +async def async_handle_intents( + hass: HomeAssistant, user_input: ConversationInput +) -> intent.IntentResponse | None: + """Try to match input against registered intents and return response. + + Returns None if no match occurred. + """ + default_agent = async_get_agent(hass) + assert isinstance(default_agent, DefaultAgent) + + return await default_agent.async_handle_intents(user_input) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 4838d19537a..c7d62c9607a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -258,45 +258,12 @@ class DefaultAgent(ConversationEntity): # Check if a trigger matched if isinstance(result, SentenceTriggerResult): - # Gather callback responses in parallel - trigger_callbacks = [ - self._trigger_sentences[trigger_id].callback( - result.sentence, trigger_result, user_input.device_id - ) - for trigger_id, trigger_result in result.matched_triggers.items() - ] - - # Use first non-empty result as response. - # - # There may be multiple copies of a trigger running when editing in - # the UI, so it's critical that we filter out empty responses here. - response_text: str | None = None - response_set_by_trigger = False - for trigger_future in asyncio.as_completed(trigger_callbacks): - trigger_response = await trigger_future - if trigger_response is None: - continue - - response_text = trigger_response - response_set_by_trigger = True - break + # Process callbacks and get response + response_text = await self._handle_trigger_result(result, user_input) # Convert to conversation result response = intent.IntentResponse(language=language) response.response_type = intent.IntentResponseType.ACTION_DONE - - if response_set_by_trigger: - # Response was explicitly set to empty - response_text = response_text or "" - elif not response_text: - # Use translated acknowledgment for pipeline language - translations = await translation.async_get_translations( - self.hass, language, DOMAIN, [DOMAIN] - ) - response_text = translations.get( - f"component.{DOMAIN}.conversation.agent.done", "Done" - ) - response.async_set_speech(response_text) return ConversationResult(response=response) @@ -439,7 +406,7 @@ class DefaultAgent(ConversationEntity): ) -> RecognizeResult | None: """Search intents for a match to user input.""" strict_result = self._recognize_strict( - user_input, lang_intents, slot_lists, intent_context, language + user_input.text, lang_intents, slot_lists, intent_context, language ) if strict_result is not None: @@ -483,7 +450,7 @@ class DefaultAgent(ConversationEntity): } strict_result = self._recognize_strict( - user_input, + user_input.text, lang_intents, slot_lists, intent_context, @@ -570,7 +537,7 @@ class DefaultAgent(ConversationEntity): def _recognize_strict( self, - user_input: ConversationInput, + sentence: str, lang_intents: LanguageIntents, slot_lists: dict[str, SlotList], intent_context: dict[str, Any] | None, @@ -1098,6 +1065,175 @@ class DefaultAgent(ConversationEntity): return SentenceTriggerResult(sentence, matched_template, matched_triggers) + async def _handle_trigger_result( + self, result: SentenceTriggerResult, user_input: ConversationInput + ) -> str: + """Run sentence trigger callbacks and return response text.""" + + # Gather callback responses in parallel + trigger_callbacks = [ + self._trigger_sentences[trigger_id].callback( + user_input.text, trigger_result, user_input.device_id + ) + for trigger_id, trigger_result in result.matched_triggers.items() + ] + + # Use first non-empty result as response. + # + # There may be multiple copies of a trigger running when editing in + # the UI, so it's critical that we filter out empty responses here. + response_text = "" + response_set_by_trigger = False + for trigger_future in asyncio.as_completed(trigger_callbacks): + trigger_response = await trigger_future + if trigger_response is None: + continue + + response_text = trigger_response + response_set_by_trigger = True + break + + if response_set_by_trigger: + # Response was explicitly set to empty + response_text = response_text or "" + elif not response_text: + # Use translated acknowledgment for pipeline language + language = user_input.language or self.hass.config.language + translations = await translation.async_get_translations( + self.hass, language, DOMAIN, [DOMAIN] + ) + response_text = translations.get(f"component.{DOMAIN}.agent.done", "Done") + + return response_text + + async def async_handle_sentence_triggers( + self, user_input: ConversationInput + ) -> str | None: + """Try to input sentence against sentence triggers and return response text. + + Returns None if no match occurred. + """ + if trigger_result := await self._match_triggers(user_input.text): + return await self._handle_trigger_result(trigger_result, user_input) + + return None + + async def async_handle_intents( + self, + user_input: ConversationInput, + ) -> intent.IntentResponse | None: + """Try to match sentence against registered intents and return response. + + Only performs strict matching with exposed entities and exact wording. + Returns None if no match occurred. + """ + language = user_input.language or self.hass.config.language + if language == MATCH_ALL: + language = self.hass.config.language + + lang_intents = await self.async_get_or_load_intents(language) + if lang_intents is None: + return None + + slot_lists = self._make_slot_lists() + intent_context = self._make_intent_context(user_input) + + result = await self.hass.async_add_executor_job( + self._recognize_strict, + user_input.text, + lang_intents, + slot_lists, + intent_context, + language, + ) + if result is None: + return None + + # Slot values to pass to the intent + slots: dict[str, Any] = { + entity.name: { + "value": entity.value, + "text": entity.text or entity.value, + } + for entity in result.entities_list + } + device_area = self._get_device_area(user_input.device_id) + if device_area: + slots["preferred_area_id"] = {"value": device_area.id} + + # Trace + async_conversation_trace_append( + ConversationTraceEventType.TOOL_CALL, + { + "intent_name": result.intent.name, + "slots": { + entity.name: entity.value or entity.text + for entity in result.entities_list + }, + }, + ) + + try: + intent_response = await intent.async_handle( + self.hass, + DOMAIN, + result.intent.name, + slots, + user_input.text, + None, + language, + assistant=DOMAIN, + device_id=user_input.device_id, + ) + except intent.MatchFailedError as match_error: + # Intent was valid, but no entities matched the constraints. + error_response_type, error_response_args = _get_match_error_response( + self.hass, match_error + ) + return _make_error_response( + language, + intent.IntentResponseErrorCode.NO_VALID_TARGETS, + self._get_error_text( + error_response_type, lang_intents, **error_response_args + ), + ) + except intent.IntentHandleError as err: + # Intent was valid and entities matched constraints, but an error + # occurred during handling. + _LOGGER.exception("Intent handling error") + return _make_error_response( + language, + intent.IntentResponseErrorCode.FAILED_TO_HANDLE, + self._get_error_text( + err.response_key or ErrorKey.HANDLE_ERROR, lang_intents + ), + ) + except intent.IntentUnexpectedError: + _LOGGER.exception("Unexpected intent error") + return _make_error_response( + language, + intent.IntentResponseErrorCode.UNKNOWN, + self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), + ) + + if ( + (not intent_response.speech) + and (intent_response.intent is not None) + and (response_key := result.response) + ): + # Use response template, if available + response_template_str = lang_intents.intent_responses.get( + result.intent.name, {} + ).get(response_key) + if response_template_str: + response_template = template.Template(response_template_str, self.hass) + speech = await self._build_speech( + language, response_template, intent_response, result + ) + intent_response.async_set_speech(speech) + + return intent_response + def _make_error_result( language: str, @@ -1105,11 +1241,20 @@ def _make_error_result( response_text: str, conversation_id: str | None = None, ) -> ConversationResult: + """Create conversation result with error code and text.""" + response = _make_error_response(language, error_code, response_text) + return ConversationResult(response, conversation_id) + + +def _make_error_response( + language: str, + error_code: intent.IntentResponseErrorCode, + response_text: str, +) -> intent.IntentResponse: """Create conversation result with error code and text.""" response = intent.IntentResponse(language=language) response.async_set_error(error_code, response_text) - - return ConversationResult(response, conversation_id) + return response def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]: diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 0d24ddbf39f..519d3093471 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -204,9 +204,11 @@ class GoogleGenerativeAIConversationEntity( """Process a sentence.""" result = conversation.ConversationResult( response=intent.IntentResponse(language=user_input.language), - conversation_id=user_input.conversation_id - if user_input.conversation_id in self.history - else ulid.ulid_now(), + conversation_id=( + user_input.conversation_id + if user_input.conversation_id in self.history + else ulid.ulid_now() + ), ) assert result.conversation_id @@ -234,6 +236,11 @@ class GoogleGenerativeAIConversationEntity( f"Error preparing LLM API: {err}", ) return result + + if external_result := await llm_api.api.async_handle_externally(user_input): + # Handled externally + return external_result + tools = [ _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools ] @@ -297,9 +304,9 @@ class GoogleGenerativeAIConversationEntity( trace.ConversationTraceEventType.AGENT_DETAIL, { # Make a copy to attach it to the trace event. - "messages": messages[:] - if supports_system_instruction - else messages[2:], + "messages": ( + messages[:] if supports_system_instruction else messages[2:] + ), "prompt": prompt, "tools": [*llm_api.tools] if llm_api else None, }, diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 1a91c790d27..cd0d9263ec9 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -172,6 +172,11 @@ class OllamaConversationEntity( return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id ) + + if external_result := await llm_api.api.async_handle_externally(user_input): + # Handled externally + return external_result + tools = [ _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools ] diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 9c73766c8d4..9a687000f03 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -153,6 +153,11 @@ class OpenAIConversationEntity( return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id ) + + if external_result := await llm_api.api.async_handle_externally(user_input): + # Handled externally + return external_result + tools = [ _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools ] diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index d322810b0ef..b401b7a54a8 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -16,8 +16,12 @@ from voluptuous_openapi import UNSUPPORTED, convert from homeassistant.components.climate import INTENT_GET_TEMPERATURE from homeassistant.components.conversation import ( + ConversationInput, + ConversationResult, ConversationTraceEventType, async_conversation_trace_append, + async_handle_intents, + async_handle_sentence_triggers, ) from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose @@ -193,6 +197,15 @@ class API(ABC): """Return the instance of the API.""" raise NotImplementedError + async def async_handle_externally( + self, user_input: ConversationInput + ) -> ConversationResult | None: + """Try to handle the input using an external system or agent. + + Returns None if the input could not be handled externally. + """ + return None + class IntentTool(Tool): """LLM Tool representing an Intent.""" @@ -310,6 +323,22 @@ class AssistAPI(API): custom_serializer=_selector_serializer, ) + async def async_handle_externally( + self, user_input: ConversationInput + ) -> ConversationResult | None: + """Try to handle the input using sentence triggers.""" + if trigger_response := await async_handle_sentence_triggers( + self.hass, user_input + ): + response = intent.IntentResponse(user_input.language) + response.async_set_speech(trigger_response) + return ConversationResult(response) + + if intent_response := await async_handle_intents(self.hass, user_input): + return ConversationResult(intent_response) + + return None + @callback def _async_get_api_prompt( self, llm_context: LLMContext, exposed_entities: dict | None @@ -495,9 +524,11 @@ def _get_exposed_entities( info["areas"] = ", ".join(area_names) if attributes := { - attr_name: str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) for attr_name, attr_value in state.attributes.items() if attr_name in interesting_attributes }: diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 65ede877281..4c28abdc8ac 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -430,6 +430,73 @@ async def test_assist_api_tools_conversion( assert tools +async def test_assist_api_handled_externally( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, +) -> None: + """Test that the Assist API handles sentence triggers and registered intents.""" + agent_id = "conversation.claude" + context = Context() + + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["my trigger"], + }, + "action": { + "set_conversation_response": "my response", + }, + } + }, + ) + + # Handled by sentence trigger instead of LLM + result = await conversation.async_converse( + hass, + "my trigger", + None, + context, + agent_id=agent_id, + ) + assert result is not None + assert result.response.speech["plain"]["speech"] == "my response" + + # Reuse custom sentences in test config to trigger default agent. + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + def __init__(self) -> None: + super().__init__() + self.was_handled = False + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + self.was_handled = True + return intent_obj.create_response() + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Handled by registered intent instead of LLM + result = await conversation.async_converse( + hass, + "I'd like to order a stout", + None, + context, + agent_id=agent_id, + ) + assert result is not None + assert result.response.intent is not None + assert result.response.intent.intent_type == handler.intent_type + assert handler.was_handled + + async def test_unknown_hass_api( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index e92b1ab538f..0100e62cf81 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -8,10 +8,15 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation -from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation import ( + ConversationInput, + async_handle_intents, + async_handle_sentence_triggers, + default_agent, +) from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent from homeassistant.setup import async_setup_component @@ -229,3 +234,93 @@ async def test_prepare_agent( await conversation.async_prepare_agent(hass, agent_id, "en") assert len(mock_prepare.mock_calls) == 1 + + +async def test_async_handle_sentence_triggers(hass: HomeAssistant) -> None: + """Test handling sentence triggers with async_handle_sentence_triggers.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + + response_template = "response {{ trigger.device_id }}" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["my trigger"], + }, + "action": { + "set_conversation_response": response_template, + }, + } + }, + ) + + # Device id will be available in response template + device_id = "1234" + expected_response = f"response {device_id}" + actual_response = await async_handle_sentence_triggers( + hass, + ConversationInput( + text="my trigger", + context=Context(), + conversation_id=None, + device_id=device_id, + language=hass.config.language, + ), + ) + assert actual_response == expected_response + + +async def test_async_handle_intents(hass: HomeAssistant) -> None: + """Test handling registered intents with async_handle_intents.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + + # Reuse custom sentences in test config to trigger default agent. + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + def __init__(self) -> None: + super().__init__() + self.was_handled = False + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + self.was_handled = True + return intent_obj.create_response() + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Registered intent will be handled + result = await async_handle_intents( + hass, + ConversationInput( + text="I'd like to order a stout", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + ), + ) + assert result is not None + assert result.intent is not None + assert result.intent.intent_type == handler.intent_type + assert handler.was_handled + + # No error messages, just None as a result + result = await async_handle_intents( + hass, + ConversationInput( + text="this sentence does not exist", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + ), + ) + assert result is None diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 4192a60513e..2645872b7fd 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -24,6 +24,7 @@ from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -458,6 +459,73 @@ async def test_function_exception( ) +@pytest.mark.usefixtures("mock_init_component") +async def test_assist_api_handled_externally( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, +) -> None: + """Test that the Assist API handles sentence triggers and registered intents.""" + agent_id = mock_config_entry_with_assist.entry_id + context = Context() + + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["my trigger"], + }, + "action": { + "set_conversation_response": "my response", + }, + } + }, + ) + + # Handled by sentence trigger instead of LLM + result = await conversation.async_converse( + hass, + "my trigger", + None, + context, + agent_id=agent_id, + ) + assert result is not None + assert result.response.speech["plain"]["speech"] == "my response" + + # Reuse custom sentences in test config to trigger default agent. + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + def __init__(self) -> None: + super().__init__() + self.was_handled = False + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + self.was_handled = True + return intent_obj.create_response() + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Handled by registered intent instead of LLM + result = await conversation.async_converse( + hass, + "I'd like to order a stout", + None, + context, + agent_id=agent_id, + ) + assert result is not None + assert result.response.intent is not None + assert result.response.intent.intent_type == handler.intent_type + assert handler.was_handled + + @pytest.mark.usefixtures("mock_init_component") async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 66dc8a0c603..c06c0bd4a96 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -14,6 +14,7 @@ from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API, MATC from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -299,6 +300,75 @@ async def test_function_exception( ) +@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +async def test_assist_api_handled_externally( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, +) -> None: + """Test that the Assist API handles sentence triggers and registered intents.""" + agent_id = mock_config_entry_with_assist.entry_id + context = Context() + + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["my trigger"], + }, + "action": { + "set_conversation_response": "my response", + }, + } + }, + ) + + # Handled by sentence trigger instead of LLM + result = await conversation.async_converse( + hass, + "my trigger", + None, + context, + agent_id=agent_id, + ) + assert result is not None + assert result.response.speech["plain"]["speech"] == "my response" + + # Reuse custom sentences in test config to trigger default agent. + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + def __init__(self) -> None: + super().__init__() + self.was_handled = False + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + self.was_handled = True + return intent_obj.create_response() + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Handled by registered intent instead of LLM + result = await conversation.async_converse( + hass, + "I'd like to order a stout", + None, + context, + agent_id=agent_id, + ) + assert result is not None + assert result.response.intent is not None + assert result.response.intent.intent_type == handler.intent_type + assert handler.was_handled + + async def test_unknown_hass_api( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index e0665bc449f..a02622f4042 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -506,6 +506,73 @@ async def test_assist_api_tools_conversion( assert tools +async def test_assist_api_handled_externally( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, +) -> None: + """Test that the Assist API handles sentence triggers and registered intents.""" + agent_id = mock_config_entry_with_assist.entry_id + context = Context() + + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["my trigger"], + }, + "action": { + "set_conversation_response": "my response", + }, + } + }, + ) + + # Handled by sentence trigger instead of LLM + result = await conversation.async_converse( + hass, + "my trigger", + None, + context, + agent_id=agent_id, + ) + assert result is not None + assert result.response.speech["plain"]["speech"] == "my response" + + # Reuse custom sentences in test config to trigger default agent. + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + def __init__(self) -> None: + super().__init__() + self.was_handled = False + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + self.was_handled = True + return intent_obj.create_response() + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Handled by registered intent instead of LLM + result = await conversation.async_converse( + hass, + "I'd like to order a stout", + None, + context, + agent_id=agent_id, + ) + assert result is not None + assert result.response.intent is not None + assert result.response.intent.intent_type == handler.intent_type + assert handler.was_handled + + async def test_unknown_hass_api( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 5ac873939f9dddf0a4971c24c85cece15ddbd2bb Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 29 Oct 2024 09:29:34 -0500 Subject: [PATCH 02/12] Remove from LLM --- .../components/anthropic/conversation.py | 5 -- .../conversation.py | 19 ++--- .../components/ollama/conversation.py | 5 -- .../openai_conversation/conversation.py | 5 -- homeassistant/helpers/llm.py | 37 +--------- .../components/anthropic/test_conversation.py | 67 ------------------ .../test_conversation.py | 68 ------------------ tests/components/ollama/test_conversation.py | 70 ------------------- .../openai_conversation/test_conversation.py | 67 ------------------ 9 files changed, 9 insertions(+), 334 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index c2300aab9ed..20e555e9592 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -159,11 +159,6 @@ class AnthropicConversationEntity( return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id ) - - if external_result := await llm_api.api.async_handle_externally(user_input): - # Handled externally - return external_result - tools = [ _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools ] diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 519d3093471..0d24ddbf39f 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -204,11 +204,9 @@ class GoogleGenerativeAIConversationEntity( """Process a sentence.""" result = conversation.ConversationResult( response=intent.IntentResponse(language=user_input.language), - conversation_id=( - user_input.conversation_id - if user_input.conversation_id in self.history - else ulid.ulid_now() - ), + conversation_id=user_input.conversation_id + if user_input.conversation_id in self.history + else ulid.ulid_now(), ) assert result.conversation_id @@ -236,11 +234,6 @@ class GoogleGenerativeAIConversationEntity( f"Error preparing LLM API: {err}", ) return result - - if external_result := await llm_api.api.async_handle_externally(user_input): - # Handled externally - return external_result - tools = [ _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools ] @@ -304,9 +297,9 @@ class GoogleGenerativeAIConversationEntity( trace.ConversationTraceEventType.AGENT_DETAIL, { # Make a copy to attach it to the trace event. - "messages": ( - messages[:] if supports_system_instruction else messages[2:] - ), + "messages": messages[:] + if supports_system_instruction + else messages[2:], "prompt": prompt, "tools": [*llm_api.tools] if llm_api else None, }, diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index cd0d9263ec9..1a91c790d27 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -172,11 +172,6 @@ class OllamaConversationEntity( return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id ) - - if external_result := await llm_api.api.async_handle_externally(user_input): - # Handled externally - return external_result - tools = [ _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools ] diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 9a687000f03..9c73766c8d4 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -153,11 +153,6 @@ class OpenAIConversationEntity( return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id ) - - if external_result := await llm_api.api.async_handle_externally(user_input): - # Handled externally - return external_result - tools = [ _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools ] diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index b401b7a54a8..d322810b0ef 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -16,12 +16,8 @@ from voluptuous_openapi import UNSUPPORTED, convert from homeassistant.components.climate import INTENT_GET_TEMPERATURE from homeassistant.components.conversation import ( - ConversationInput, - ConversationResult, ConversationTraceEventType, async_conversation_trace_append, - async_handle_intents, - async_handle_sentence_triggers, ) from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose @@ -197,15 +193,6 @@ class API(ABC): """Return the instance of the API.""" raise NotImplementedError - async def async_handle_externally( - self, user_input: ConversationInput - ) -> ConversationResult | None: - """Try to handle the input using an external system or agent. - - Returns None if the input could not be handled externally. - """ - return None - class IntentTool(Tool): """LLM Tool representing an Intent.""" @@ -323,22 +310,6 @@ class AssistAPI(API): custom_serializer=_selector_serializer, ) - async def async_handle_externally( - self, user_input: ConversationInput - ) -> ConversationResult | None: - """Try to handle the input using sentence triggers.""" - if trigger_response := await async_handle_sentence_triggers( - self.hass, user_input - ): - response = intent.IntentResponse(user_input.language) - response.async_set_speech(trigger_response) - return ConversationResult(response) - - if intent_response := await async_handle_intents(self.hass, user_input): - return ConversationResult(intent_response) - - return None - @callback def _async_get_api_prompt( self, llm_context: LLMContext, exposed_entities: dict | None @@ -524,11 +495,9 @@ def _get_exposed_entities( info["areas"] = ", ".join(area_names) if attributes := { - attr_name: ( - str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value - ) + attr_name: str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value for attr_name, attr_value in state.attributes.items() if attr_name in interesting_attributes }: diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 4c28abdc8ac..65ede877281 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -430,73 +430,6 @@ async def test_assist_api_tools_conversion( assert tools -async def test_assist_api_handled_externally( - hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, - mock_init_component, -) -> None: - """Test that the Assist API handles sentence triggers and registered intents.""" - agent_id = "conversation.claude" - context = Context() - - assert await async_setup_component( - hass, - "automation", - { - "automation": { - "trigger": { - "platform": "conversation", - "command": ["my trigger"], - }, - "action": { - "set_conversation_response": "my response", - }, - } - }, - ) - - # Handled by sentence trigger instead of LLM - result = await conversation.async_converse( - hass, - "my trigger", - None, - context, - agent_id=agent_id, - ) - assert result is not None - assert result.response.speech["plain"]["speech"] == "my response" - - # Reuse custom sentences in test config to trigger default agent. - class OrderBeerIntentHandler(intent.IntentHandler): - intent_type = "OrderBeer" - - def __init__(self) -> None: - super().__init__() - self.was_handled = False - - async def async_handle( - self, intent_obj: intent.Intent - ) -> intent.IntentResponse: - self.was_handled = True - return intent_obj.create_response() - - handler = OrderBeerIntentHandler() - intent.async_register(hass, handler) - - # Handled by registered intent instead of LLM - result = await conversation.async_converse( - hass, - "I'd like to order a stout", - None, - context, - agent_id=agent_id, - ) - assert result is not None - assert result.response.intent is not None - assert result.response.intent.intent_type == handler.intent_type - assert handler.was_handled - - async def test_unknown_hass_api( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 2645872b7fd..4192a60513e 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -24,7 +24,6 @@ from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -459,73 +458,6 @@ async def test_function_exception( ) -@pytest.mark.usefixtures("mock_init_component") -async def test_assist_api_handled_externally( - hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, -) -> None: - """Test that the Assist API handles sentence triggers and registered intents.""" - agent_id = mock_config_entry_with_assist.entry_id - context = Context() - - assert await async_setup_component( - hass, - "automation", - { - "automation": { - "trigger": { - "platform": "conversation", - "command": ["my trigger"], - }, - "action": { - "set_conversation_response": "my response", - }, - } - }, - ) - - # Handled by sentence trigger instead of LLM - result = await conversation.async_converse( - hass, - "my trigger", - None, - context, - agent_id=agent_id, - ) - assert result is not None - assert result.response.speech["plain"]["speech"] == "my response" - - # Reuse custom sentences in test config to trigger default agent. - class OrderBeerIntentHandler(intent.IntentHandler): - intent_type = "OrderBeer" - - def __init__(self) -> None: - super().__init__() - self.was_handled = False - - async def async_handle( - self, intent_obj: intent.Intent - ) -> intent.IntentResponse: - self.was_handled = True - return intent_obj.create_response() - - handler = OrderBeerIntentHandler() - intent.async_register(hass, handler) - - # Handled by registered intent instead of LLM - result = await conversation.async_converse( - hass, - "I'd like to order a stout", - None, - context, - agent_id=agent_id, - ) - assert result is not None - assert result.response.intent is not None - assert result.response.intent.intent_type == handler.intent_type - assert handler.was_handled - - @pytest.mark.usefixtures("mock_init_component") async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index c06c0bd4a96..66dc8a0c603 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -14,7 +14,6 @@ from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API, MATC from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -300,75 +299,6 @@ async def test_function_exception( ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") -async def test_assist_api_handled_externally( - mock_get_tools, - hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, - mock_init_component, -) -> None: - """Test that the Assist API handles sentence triggers and registered intents.""" - agent_id = mock_config_entry_with_assist.entry_id - context = Context() - - assert await async_setup_component( - hass, - "automation", - { - "automation": { - "trigger": { - "platform": "conversation", - "command": ["my trigger"], - }, - "action": { - "set_conversation_response": "my response", - }, - } - }, - ) - - # Handled by sentence trigger instead of LLM - result = await conversation.async_converse( - hass, - "my trigger", - None, - context, - agent_id=agent_id, - ) - assert result is not None - assert result.response.speech["plain"]["speech"] == "my response" - - # Reuse custom sentences in test config to trigger default agent. - class OrderBeerIntentHandler(intent.IntentHandler): - intent_type = "OrderBeer" - - def __init__(self) -> None: - super().__init__() - self.was_handled = False - - async def async_handle( - self, intent_obj: intent.Intent - ) -> intent.IntentResponse: - self.was_handled = True - return intent_obj.create_response() - - handler = OrderBeerIntentHandler() - intent.async_register(hass, handler) - - # Handled by registered intent instead of LLM - result = await conversation.async_converse( - hass, - "I'd like to order a stout", - None, - context, - agent_id=agent_id, - ) - assert result is not None - assert result.response.intent is not None - assert result.response.intent.intent_type == handler.intent_type - assert handler.was_handled - - async def test_unknown_hass_api( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index a02622f4042..e0665bc449f 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -506,73 +506,6 @@ async def test_assist_api_tools_conversion( assert tools -async def test_assist_api_handled_externally( - hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, - mock_init_component, -) -> None: - """Test that the Assist API handles sentence triggers and registered intents.""" - agent_id = mock_config_entry_with_assist.entry_id - context = Context() - - assert await async_setup_component( - hass, - "automation", - { - "automation": { - "trigger": { - "platform": "conversation", - "command": ["my trigger"], - }, - "action": { - "set_conversation_response": "my response", - }, - } - }, - ) - - # Handled by sentence trigger instead of LLM - result = await conversation.async_converse( - hass, - "my trigger", - None, - context, - agent_id=agent_id, - ) - assert result is not None - assert result.response.speech["plain"]["speech"] == "my response" - - # Reuse custom sentences in test config to trigger default agent. - class OrderBeerIntentHandler(intent.IntentHandler): - intent_type = "OrderBeer" - - def __init__(self) -> None: - super().__init__() - self.was_handled = False - - async def async_handle( - self, intent_obj: intent.Intent - ) -> intent.IntentResponse: - self.was_handled = True - return intent_obj.create_response() - - handler = OrderBeerIntentHandler() - intent.async_register(hass, handler) - - # Handled by registered intent instead of LLM - result = await conversation.async_converse( - hass, - "I'd like to order a stout", - None, - context, - agent_id=agent_id, - ) - assert result is not None - assert result.response.intent is not None - assert result.response.intent.intent_type == handler.intent_type - assert handler.was_handled - - async def test_unknown_hass_api( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From b20e12f1c84dbddfff5848fa927669d389a78812 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 29 Oct 2024 11:37:52 -0500 Subject: [PATCH 03/12] Check sentence triggers and local intents first --- .../components/assist_pipeline/pipeline.py | 64 +++++++- .../components/conversation/default_agent.py | 155 ++++-------------- tests/components/assist_pipeline/test_init.py | 154 ++++++++++++++++- .../assist_pipeline/test_pipeline.py | 2 + .../assist_pipeline/test_websocket.py | 10 ++ 5 files changed, 261 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a55e23ae051..ad5f7da3fe9 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -29,8 +29,10 @@ from homeassistant.components import ( from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) +from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent from homeassistant.helpers.collection import ( CHANGE_UPDATED, CollectionError, @@ -109,6 +111,7 @@ PIPELINE_FIELDS: VolDictType = { vol.Required("tts_voice"): vol.Any(str, None), vol.Required("wake_word_entity"): vol.Any(str, None), vol.Required("wake_word_id"): vol.Any(str, None), + vol.Optional("prefer_local_intents"): bool, } STORED_PIPELINE_RUNS = 10 @@ -322,6 +325,7 @@ async def async_update_pipeline( tts_voice: str | None | UndefinedType = UNDEFINED, wake_word_entity: str | None | UndefinedType = UNDEFINED, wake_word_id: str | None | UndefinedType = UNDEFINED, + prefer_local_intents: bool | UndefinedType = UNDEFINED, ) -> None: """Update a pipeline.""" pipeline_data: PipelineData = hass.data[DOMAIN] @@ -345,6 +349,7 @@ async def async_update_pipeline( ("tts_voice", tts_voice), ("wake_word_entity", wake_word_entity), ("wake_word_id", wake_word_id), + ("prefer_local_intents", prefer_local_intents), ) if val is not UNDEFINED } @@ -398,6 +403,7 @@ class Pipeline: tts_voice: str | None wake_word_entity: str | None wake_word_id: str | None + prefer_local_intents: bool = False id: str = field(default_factory=ulid_util.ulid_now) @@ -421,6 +427,7 @@ class Pipeline: tts_voice=data["tts_voice"], wake_word_entity=data["wake_word_entity"], wake_word_id=data["wake_word_id"], + prefer_local_intents=data.get("prefer_local_intents", False), ) def to_json(self) -> dict[str, Any]: @@ -438,6 +445,7 @@ class Pipeline: "tts_voice": self.tts_voice, "wake_word_entity": self.wake_word_entity, "wake_word_id": self.wake_word_id, + "prefer_local_intents": self.prefer_local_intents, } @@ -1016,15 +1024,65 @@ class PipelineRun: ) try: - conversation_result = await conversation.async_converse( - hass=self.hass, + user_input = conversation.ConversationInput( text=intent_input, + context=self.context, conversation_id=conversation_id, device_id=device_id, - context=self.context, language=self.pipeline.conversation_language, agent_id=self.intent_agent, ) + if user_input.language == MATCH_ALL: + # We only load local intents for one language + user_input.language = ( + self.pipeline.stt_language + or self.pipeline.tts_language + or self.hass.config.language + ) + + # Sentence triggers override conversation agent + if ( + trigger_response_text + := await conversation.async_handle_sentence_triggers( + self.hass, user_input + ) + ): + # Sentence trigger matched + trigger_response = intent.IntentResponse( + self.pipeline.conversation_language + ) + trigger_response.async_set_speech(trigger_response_text) + conversation_result = conversation.ConversationResult( + response=trigger_response, + conversation_id=user_input.conversation_id, + ) + # Try local intents first, if preferred. + # Skip this step if the default agent is already used. + elif ( + self.pipeline.prefer_local_intents + and (user_input.agent_id != conversation.HOME_ASSISTANT_AGENT) + and ( + intent_response := await conversation.async_handle_intents( + self.hass, user_input + ) + ) + ): + # Local intent matched + conversation_result = conversation.ConversationResult( + response=intent_response, + conversation_id=user_input.conversation_id, + ) + else: + # Fall back to pipeline conversation agent + conversation_result = await conversation.async_converse( + hass=self.hass, + text=user_input.text, + conversation_id=user_input.conversation_id, + device_id=user_input.device_id, + context=user_input.context, + language=user_input.language, + agent_id=user_input.agent_id, + ) except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") raise IntentRecognitionError( diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c7d62c9607a..e60fcd66afa 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -214,10 +214,15 @@ class DefaultAgent(ConversationEntity): ] async def async_recognize( - self, user_input: ConversationInput + self, + user_input: ConversationInput, + strict_intents_only: bool = False, + match_sentence_triggers: bool = True, ) -> RecognizeResult | SentenceTriggerResult | None: """Recognize intent from user input.""" - if trigger_result := await self._match_triggers(user_input.text): + if (match_sentence_triggers) and ( + trigger_result := await self._match_triggers(user_input.text) + ): return trigger_result language = user_input.language or self.hass.config.language @@ -240,6 +245,7 @@ class DefaultAgent(ConversationEntity): slot_lists, intent_context, language, + strict_intents_only, ) _LOGGER.debug( @@ -251,9 +257,6 @@ class DefaultAgent(ConversationEntity): async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" - language = user_input.language or self.hass.config.language - conversation_id = None # Not supported - result = await self.async_recognize(user_input) # Check if a trigger matched @@ -262,12 +265,25 @@ class DefaultAgent(ConversationEntity): response_text = await self._handle_trigger_result(result, user_input) # Convert to conversation result - response = intent.IntentResponse(language=language) + response = intent.IntentResponse( + language=user_input.language or self.hass.config.language + ) response.response_type = intent.IntentResponseType.ACTION_DONE response.async_set_speech(response_text) return ConversationResult(response=response) + return await self._async_process_intent_result(result, user_input) + + async def _async_process_intent_result( + self, + result: RecognizeResult | None, + user_input: ConversationInput, + ) -> ConversationResult: + """Process user input with intents.""" + language = user_input.language or self.hass.config.language + conversation_id = None # Not supported + # Intent match or failure lang_intents = await self.async_get_or_load_intents(language) @@ -403,6 +419,7 @@ class DefaultAgent(ConversationEntity): slot_lists: dict[str, SlotList], intent_context: dict[str, Any] | None, language: str, + strict_intents_only: bool, ) -> RecognizeResult | None: """Search intents for a match to user input.""" strict_result = self._recognize_strict( @@ -413,6 +430,9 @@ class DefaultAgent(ConversationEntity): # Successful strict match return strict_result + if strict_intents_only: + return None + # Try again with all entities (including unexposed) entity_registry = er.async_get(self.hass) all_entity_names: list[tuple[str, str, dict[str, Any]]] = [] @@ -1127,112 +1147,17 @@ class DefaultAgent(ConversationEntity): Only performs strict matching with exposed entities and exact wording. Returns None if no match occurred. """ - language = user_input.language or self.hass.config.language - if language == MATCH_ALL: - language = self.hass.config.language - - lang_intents = await self.async_get_or_load_intents(language) - if lang_intents is None: + result = await self.async_recognize( + user_input, strict_intents_only=True, match_sentence_triggers=False + ) + if not isinstance(result, RecognizeResult): + # No error message on failed match return None - slot_lists = self._make_slot_lists() - intent_context = self._make_intent_context(user_input) - - result = await self.hass.async_add_executor_job( - self._recognize_strict, - user_input.text, - lang_intents, - slot_lists, - intent_context, - language, + conversation_result = await self._async_process_intent_result( + result, user_input ) - if result is None: - return None - - # Slot values to pass to the intent - slots: dict[str, Any] = { - entity.name: { - "value": entity.value, - "text": entity.text or entity.value, - } - for entity in result.entities_list - } - device_area = self._get_device_area(user_input.device_id) - if device_area: - slots["preferred_area_id"] = {"value": device_area.id} - - # Trace - async_conversation_trace_append( - ConversationTraceEventType.TOOL_CALL, - { - "intent_name": result.intent.name, - "slots": { - entity.name: entity.value or entity.text - for entity in result.entities_list - }, - }, - ) - - try: - intent_response = await intent.async_handle( - self.hass, - DOMAIN, - result.intent.name, - slots, - user_input.text, - None, - language, - assistant=DOMAIN, - device_id=user_input.device_id, - ) - except intent.MatchFailedError as match_error: - # Intent was valid, but no entities matched the constraints. - error_response_type, error_response_args = _get_match_error_response( - self.hass, match_error - ) - return _make_error_response( - language, - intent.IntentResponseErrorCode.NO_VALID_TARGETS, - self._get_error_text( - error_response_type, lang_intents, **error_response_args - ), - ) - except intent.IntentHandleError as err: - # Intent was valid and entities matched constraints, but an error - # occurred during handling. - _LOGGER.exception("Intent handling error") - return _make_error_response( - language, - intent.IntentResponseErrorCode.FAILED_TO_HANDLE, - self._get_error_text( - err.response_key or ErrorKey.HANDLE_ERROR, lang_intents - ), - ) - except intent.IntentUnexpectedError: - _LOGGER.exception("Unexpected intent error") - return _make_error_response( - language, - intent.IntentResponseErrorCode.UNKNOWN, - self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), - ) - - if ( - (not intent_response.speech) - and (intent_response.intent is not None) - and (response_key := result.response) - ): - # Use response template, if available - response_template_str = lang_intents.intent_responses.get( - result.intent.name, {} - ).get(response_key) - if response_template_str: - response_template = template.Template(response_template_str, self.hass) - speech = await self._build_speech( - language, response_template, intent_response, result - ) - intent_response.async_set_speech(speech) - - return intent_response + return conversation_result.response def _make_error_result( @@ -1241,20 +1166,10 @@ def _make_error_result( response_text: str, conversation_id: str | None = None, ) -> ConversationResult: - """Create conversation result with error code and text.""" - response = _make_error_response(language, error_code, response_text) - return ConversationResult(response, conversation_id) - - -def _make_error_response( - language: str, - error_code: intent.IntentResponseErrorCode, - response_text: str, -) -> intent.IntentResponse: """Create conversation result with error code and text.""" response = intent.IntentResponse(language=language) response.async_set_error(error_code, response_text) - return response + return ConversationResult(response, conversation_id) def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]: diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index c4696573bad..bdca27d527f 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -11,13 +11,20 @@ import wave import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import assist_pipeline, media_source, stt, tts +from homeassistant.components import ( + assist_pipeline, + conversation, + media_source, + stt, + tts, +) from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, CONF_DEBUG_RECORDING_DIR, DOMAIN, ) from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent from homeassistant.setup import async_setup_component from .conftest import ( @@ -927,3 +934,148 @@ async def test_tts_dict_preferred_format( assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 48000 assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 2 assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 + + +async def test_sentence_trigger_overrides_conversation_agent( + hass: HomeAssistant, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that sentence triggers are checked before the conversation agent.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": [ + "test trigger sentence", + ], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="test trigger sentence", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" + ) as mock_async_converse: + await pipeline_input.execute() + + # Sentence trigger should have been handled + mock_async_converse.assert_not_called() + + # Verify sentence trigger response + intent_end_event = next( + ( + e + for e in events + if e.type == assist_pipeline.PipelineEventType.INTENT_END + ), + None, + ) + assert (intent_end_event is not None) and intent_end_event.data + assert ( + intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ + "speech" + ] + == "test trigger response" + ) + + +async def test_prefer_local_intents( + hass: HomeAssistant, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that the default agent is checked first when local intents are preferred.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Reuse custom sentences in test config + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + response = intent_obj.create_response() + response.async_set_speech("Order confirmed") + return response + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent", prefer_local_intents=True + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="I'd like to order a stout please", + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse" + ) as mock_async_converse: + await pipeline_input.execute() + + # Test agent should not have been called + mock_async_converse.assert_not_called() + + # Verify local intent response + intent_end_event = next( + ( + e + for e in events + if e.type == assist_pipeline.PipelineEventType.INTENT_END + ), + None, + ) + assert (intent_end_event is not None) and intent_end_event.data + assert ( + intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ + "speech" + ] + == "Order confirmed" + ) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 50d0fc9bed8..d52e2a762ee 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -574,6 +574,7 @@ async def test_update_pipeline( "tts_voice": "test_voice", "wake_word_entity": "wake_work.test_1", "wake_word_id": "wake_word_id_1", + "prefer_local_intents": False, } await async_update_pipeline( @@ -617,6 +618,7 @@ async def test_update_pipeline( "tts_voice": "test_voice", "wake_word_entity": "wake_work.test_1", "wake_word_id": "wake_word_id_1", + "prefer_local_intents": False, } diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index e339ee74fbb..c9bc3ef41de 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -974,6 +974,7 @@ async def test_add_pipeline( "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": "wakeword_entity_1", "wake_word_id": "wakeword_id_1", + "prefer_local_intents": True, } ) msg = await client.receive_json() @@ -991,6 +992,7 @@ async def test_add_pipeline( "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": "wakeword_entity_1", "wake_word_id": "wakeword_id_1", + "prefer_local_intents": True, } assert len(pipeline_store.data) == 2 @@ -1008,6 +1010,7 @@ async def test_add_pipeline( tts_voice="Arnold Schwarzenegger", wake_word_entity="wakeword_entity_1", wake_word_id="wakeword_id_1", + prefer_local_intents=True, ) await client.send_json_auto_id( @@ -1195,6 +1198,7 @@ async def test_get_pipeline( "tts_voice": "james_earl_jones", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, } # Get conversation agent as pipeline @@ -1220,6 +1224,7 @@ async def test_get_pipeline( "tts_voice": "james_earl_jones", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, } await client.send_json_auto_id( @@ -1249,6 +1254,7 @@ async def test_get_pipeline( "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": "wakeword_entity_1", "wake_word_id": "wakeword_id_1", + "prefer_local_intents": False, } ) msg = await client.receive_json() @@ -1277,6 +1283,7 @@ async def test_get_pipeline( "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": "wakeword_entity_1", "wake_word_id": "wakeword_id_1", + "prefer_local_intents": False, } @@ -1304,6 +1311,7 @@ async def test_list_pipelines( "tts_voice": "james_earl_jones", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, } ], "preferred_pipeline": ANY, @@ -1395,6 +1403,7 @@ async def test_update_pipeline( "tts_voice": "new_tts_voice", "wake_word_entity": "new_wakeword_entity", "wake_word_id": "new_wakeword_id", + "prefer_local_intents": False, } assert len(pipeline_store.data) == 2 @@ -1446,6 +1455,7 @@ async def test_update_pipeline( "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, } pipeline = pipeline_store.data[pipeline_id] From 3ce5ed63e1164e1a84a844b1a0a4334c67345df9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 29 Oct 2024 11:45:56 -0500 Subject: [PATCH 04/12] Fix type --- homeassistant/components/assist_pipeline/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index ad5f7da3fe9..c8f3ad85763 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -325,7 +325,7 @@ async def async_update_pipeline( tts_voice: str | None | UndefinedType = UNDEFINED, wake_word_entity: str | None | UndefinedType = UNDEFINED, wake_word_id: str | None | UndefinedType = UNDEFINED, - prefer_local_intents: bool | UndefinedType = UNDEFINED, + prefer_local_intents: bool | None | UndefinedType = UNDEFINED, ) -> None: """Update a pipeline.""" pipeline_data: PipelineData = hass.data[DOMAIN] From 745861cf91f73b2352f084037c53323e2db9bb80 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 29 Oct 2024 12:08:04 -0500 Subject: [PATCH 05/12] Fix type again --- homeassistant/components/assist_pipeline/pipeline.py | 2 +- homeassistant/components/cloud/assist_pipeline.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index c8f3ad85763..ad5f7da3fe9 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -325,7 +325,7 @@ async def async_update_pipeline( tts_voice: str | None | UndefinedType = UNDEFINED, wake_word_entity: str | None | UndefinedType = UNDEFINED, wake_word_id: str | None | UndefinedType = UNDEFINED, - prefer_local_intents: bool | None | UndefinedType = UNDEFINED, + prefer_local_intents: bool | UndefinedType = UNDEFINED, ) -> None: """Update a pipeline.""" pipeline_data: PipelineData = hass.data[DOMAIN] diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py index f3a591d6eda..c97e5bdc0a2 100644 --- a/homeassistant/components/cloud/assist_pipeline.py +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -1,6 +1,7 @@ """Handle Cloud assist pipelines.""" import asyncio +from typing import Any from homeassistant.components.assist_pipeline import ( async_create_default_pipeline, @@ -98,7 +99,7 @@ async def async_migrate_cloud_pipeline_engine( # is an after dependency of cloud await async_setup_pipeline_store(hass) - kwargs: dict[str, str] = {pipeline_attribute: engine_id} + kwargs: dict[str, Any] = {pipeline_attribute: engine_id} pipelines = async_get_pipelines(hass) for pipeline in pipelines: if getattr(pipeline, pipeline_attribute) == DOMAIN: From 2e3489cadc336a1234276040c29c7aa40550d9ea Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 29 Oct 2024 12:15:37 -0500 Subject: [PATCH 06/12] Use pipeline language --- homeassistant/components/assist_pipeline/pipeline.py | 10 +--------- .../assist_pipeline/snapshots/test_init.ambr | 4 ++-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index ad5f7da3fe9..d90424d52d3 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -29,7 +29,6 @@ from homeassistant.components import ( from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) -from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent @@ -1029,16 +1028,9 @@ class PipelineRun: context=self.context, conversation_id=conversation_id, device_id=device_id, - language=self.pipeline.conversation_language, + language=self.pipeline.language, agent_id=self.intent_agent, ) - if user_input.language == MATCH_ALL: - # We only load local intents for one language - user_input.language = ( - self.pipeline.stt_language - or self.pipeline.tts_language - or self.hass.config.language - ) # Sentence triggers override conversation agent if ( diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index e14bbac1839..7f77dada3be 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -139,7 +139,7 @@ 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en-US', + 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ @@ -228,7 +228,7 @@ 'data': dict({ 'code': 'no_intent_match', }), - 'language': 'en-US', + 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ From 2e2b79fd3a9a8034a96fc976256c4d2f307bd6eb Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 29 Oct 2024 14:07:47 -0500 Subject: [PATCH 07/12] Fix cloud test --- tests/components/cloud/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 18f8cd4d311..1fb9f2b0d40 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -35,6 +35,7 @@ PIPELINE_DATA = { "tts_voice": "Arnold Schwarzenegger", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, }, { "conversation_engine": "conversation_engine_2", @@ -49,6 +50,7 @@ PIPELINE_DATA = { "tts_voice": "The Voice", "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, }, { "conversation_engine": "conversation_engine_3", @@ -63,6 +65,7 @@ PIPELINE_DATA = { "tts_voice": None, "wake_word_entity": None, "wake_word_id": None, + "prefer_local_intents": False, }, ], "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", From 472414a8d6bd231ce9f5c661248a2fdfd97eabb1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:17:08 +0100 Subject: [PATCH 08/12] Add missing translation string to smarty (#130624) --- homeassistant/components/smarty/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 188459b4f16..341a300a26e 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -28,6 +28,10 @@ "deprecated_yaml_import_issue_auth_error": { "title": "YAML import failed due to an authentication error", "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "YAML import failed due to a connection error", + "description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." } }, "entity": { From c7ee7dc880a0952dcc8b447f70747980bbb56f88 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:26:05 +0100 Subject: [PATCH 09/12] Refactor translation checks (#130585) * Refactor translation checks * Adjust * Improve * Restore await * Delay pytest.fail until the end of the test --- tests/components/conftest.py | 155 ++++++++++++++++++++--------------- 1 file changed, 91 insertions(+), 64 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 5535ec3b976..363d39a2e63 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -26,7 +26,12 @@ from homeassistant.config_entries import ( ) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowHandler, FlowManager, FlowResultType +from homeassistant.data_entry_flow import ( + FlowContext, + FlowHandler, + FlowManager, + FlowResultType, +) from homeassistant.helpers.translation import async_get_translations if TYPE_CHECKING: @@ -557,12 +562,12 @@ def _validate_translation_placeholders( description_placeholders is None or placeholder not in description_placeholders ): - pytest.fail( + ignore_translations[full_key] = ( f"Description not found for placeholder `{placeholder}` in {full_key}" ) -async def _ensure_translation_exists( +async def _validate_translation( hass: HomeAssistant, ignore_translations: dict[str, StoreInfo], category: str, @@ -588,7 +593,7 @@ async def _ensure_translation_exists( ignore_translations[full_key] = "used" return - pytest.fail( + ignore_translations[full_key] = ( f"Translation not found for {component}: `{category}.{key}`. " f"Please add to homeassistant/components/{component}/strings.json" ) @@ -604,84 +609,106 @@ def ignore_translations() -> str | list[str]: return [] +async def _check_config_flow_result_translations( + manager: FlowManager, + flow: FlowHandler, + result: FlowResult[FlowContext, str], + ignore_translations: dict[str, str], +) -> None: + if isinstance(manager, ConfigEntriesFlowManager): + category = "config" + integration = flow.handler + elif isinstance(manager, OptionsFlowManager): + category = "options" + integration = flow.hass.config_entries.async_get_entry(flow.handler).domain + else: + return + + # Check if this flow has been seen before + # Gets set to False on first run, and to True on subsequent runs + setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before")) + + if result["type"] is FlowResultType.FORM: + if step_id := result.get("step_id"): + # neither title nor description are required + # - title defaults to integration name + # - description is optional + for header in ("title", "description"): + await _validate_translation( + flow.hass, + ignore_translations, + category, + integration, + f"step.{step_id}.{header}", + result["description_placeholders"], + translation_required=False, + ) + if errors := result.get("errors"): + for error in errors.values(): + await _validate_translation( + flow.hass, + ignore_translations, + category, + integration, + f"error.{error}", + result["description_placeholders"], + ) + return + + if result["type"] is FlowResultType.ABORT: + # We don't need translations for a discovery flow which immediately + # aborts, since such flows won't be seen by users + if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES: + return + await _validate_translation( + flow.hass, + ignore_translations, + category, + integration, + f"abort.{result["reason"]}", + result["description_placeholders"], + ) + + @pytest.fixture(autouse=True) -def check_config_translations(ignore_translations: str | list[str]) -> Generator[None]: - """Ensure config_flow translations are available.""" +def check_translations(ignore_translations: str | list[str]) -> Generator[None]: + """Check that translation requirements are met. + + Current checks: + - data entry flow results (ConfigFlow/OptionsFlow) + """ if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] _ignore_translations = {k: "unused" for k in ignore_translations} - _original = FlowManager._async_handle_step - async def _async_handle_step( + # Keep reference to original functions + _original_flow_manager_async_handle_step = FlowManager._async_handle_step + + # Prepare override functions + async def _flow_manager_async_handle_step( self: FlowManager, flow: FlowHandler, *args ) -> FlowResult: - result = await _original(self, flow, *args) - if isinstance(self, ConfigEntriesFlowManager): - category = "config" - component = flow.handler - elif isinstance(self, OptionsFlowManager): - category = "options" - component = flow.hass.config_entries.async_get_entry(flow.handler).domain - else: - return result - - # Check if this flow has been seen before - # Gets set to False on first run, and to True on subsequent runs - setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before")) - - if result["type"] is FlowResultType.FORM: - if step_id := result.get("step_id"): - # neither title nor description are required - # - title defaults to integration name - # - description is optional - for header in ("title", "description"): - await _ensure_translation_exists( - flow.hass, - _ignore_translations, - category, - component, - f"step.{step_id}.{header}", - result["description_placeholders"], - translation_required=False, - ) - if errors := result.get("errors"): - for error in errors.values(): - await _ensure_translation_exists( - flow.hass, - _ignore_translations, - category, - component, - f"error.{error}", - result["description_placeholders"], - ) - return result - - if result["type"] is FlowResultType.ABORT: - # We don't need translations for a discovery flow which immediately - # aborts, since such flows won't be seen by users - if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES: - return result - await _ensure_translation_exists( - flow.hass, - _ignore_translations, - category, - component, - f"abort.{result["reason"]}", - result["description_placeholders"], - ) - + result = await _original_flow_manager_async_handle_step(self, flow, *args) + await _check_config_flow_result_translations( + self, flow, result, _ignore_translations + ) return result + # Use override functions with patch( "homeassistant.data_entry_flow.FlowManager._async_handle_step", - _async_handle_step, + _flow_manager_async_handle_step, ): yield + # Run final checks unused_ignore = [k for k, v in _ignore_translations.items() if v == "unused"] if unused_ignore: pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) + for description in _ignore_translations.values(): + if description not in {"used", "unused"}: + pytest.fail(description) From cd1272008507c7cb82155a8d7509c95067290774 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 14 Nov 2024 16:31:33 +0100 Subject: [PATCH 10/12] Add Python version to issue ID (#130611) --- homeassistant/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index dcfb6685627..1034223051c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -515,7 +515,7 @@ async def async_from_config_dict( issue_registry.async_create_issue( hass, core.DOMAIN, - "python_version", + f"python_version_{required_python_version}", is_fixable=False, severity=issue_registry.IssueSeverity.WARNING, breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE, From 1ce8bfdaa438949da707d94ff7b12ff7b20ce0cc Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:34:17 +0100 Subject: [PATCH 11/12] Use test helpers for acaia buttons (#130626) --- .../acaia/snapshots/test_button.ambr | 60 +++++++++---------- tests/components/acaia/test_button.py | 33 ++++++---- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr index 7e2624923af..cd91ca1a17a 100644 --- a/tests/components/acaia/snapshots/test_button.ambr +++ b/tests/components/acaia/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_buttons[entry_button_reset_timer] +# name: test_buttons[button.lunar_ddeeff_reset_timer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -32,7 +32,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[entry_button_start_stop_timer] +# name: test_buttons[button.lunar_ddeeff_reset_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Reset timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_reset_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.lunar_ddeeff_start_stop_timer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -65,7 +78,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[entry_button_tare] +# name: test_buttons[button.lunar_ddeeff_start_stop_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Start/stop timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_start_stop_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.lunar_ddeeff_tare-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -98,33 +124,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[state_button_reset_timer] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LUNAR-DDEEFF Reset timer', - }), - 'context': , - 'entity_id': 'button.lunar_ddeeff_reset_timer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[state_button_start_stop_timer] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LUNAR-DDEEFF Start/stop timer', - }), - 'context': , - 'entity_id': 'button.lunar_ddeeff_start_stop_timer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[state_button_tare] +# name: test_buttons[button.lunar_ddeeff_tare-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'LUNAR-DDEEFF Tare', diff --git a/tests/components/acaia/test_button.py b/tests/components/acaia/test_button.py index 62eb8b61b8a..f68f85e253d 100644 --- a/tests/components/acaia/test_button.py +++ b/tests/components/acaia/test_button.py @@ -1,21 +1,24 @@ """Tests for the acaia buttons.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -import pytest from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import async_fire_time_changed - -pytestmark = pytest.mark.usefixtures("init_integration") +from . import setup_integration +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform BUTTONS = ( "tare", @@ -28,24 +31,25 @@ async def test_buttons( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test the acaia buttons.""" - for button in BUTTONS: - state = hass.states.get(f"button.lunar_ddeeff_{button}") - assert state - assert state == snapshot(name=f"state_button_{button}") - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot(name=f"entry_button_{button}") + with patch("homeassistant.components.acaia.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_button_presses( hass: HomeAssistant, mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test the acaia button presses.""" + await setup_integration(hass, mock_config_entry) + for button in BUTTONS: await hass.services.async_call( BUTTON_DOMAIN, @@ -63,10 +67,13 @@ async def test_button_presses( async def test_buttons_unavailable_on_disconnected_scale( hass: HomeAssistant, mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test the acaia buttons are unavailable when the scale is disconnected.""" + await setup_integration(hass, mock_config_entry) + for button in BUTTONS: state = hass.states.get(f"button.lunar_ddeeff_{button}") assert state From 2e1d843ff8c19c4877c31f938e20b5fa9cead73d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 14 Nov 2024 09:35:18 -0600 Subject: [PATCH 12/12] Clean up and fix translation key --- homeassistant/components/conversation/default_agent.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index e60fcd66afa..ab9c179aaf6 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -423,7 +423,7 @@ class DefaultAgent(ConversationEntity): ) -> RecognizeResult | None: """Search intents for a match to user input.""" strict_result = self._recognize_strict( - user_input.text, lang_intents, slot_lists, intent_context, language + user_input, lang_intents, slot_lists, intent_context, language ) if strict_result is not None: @@ -470,7 +470,7 @@ class DefaultAgent(ConversationEntity): } strict_result = self._recognize_strict( - user_input.text, + user_input, lang_intents, slot_lists, intent_context, @@ -557,7 +557,7 @@ class DefaultAgent(ConversationEntity): def _recognize_strict( self, - sentence: str, + user_input: ConversationInput, lang_intents: LanguageIntents, slot_lists: dict[str, SlotList], intent_context: dict[str, Any] | None, @@ -1122,7 +1122,9 @@ class DefaultAgent(ConversationEntity): translations = await translation.async_get_translations( self.hass, language, DOMAIN, [DOMAIN] ) - response_text = translations.get(f"component.{DOMAIN}.agent.done", "Done") + response_text = translations.get( + f"component.{DOMAIN}.conversation.agent.done", "Done" + ) return response_text