diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 7bb2c2182b3..71b14f8d299 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -419,6 +419,7 @@ class DefaultAgent(ConversationEntity): language: str, ) -> RecognizeResult | None: """Search intents for a match to user input.""" + custom_result: RecognizeResult | None = None name_result: RecognizeResult | None = None best_results: list[RecognizeResult] = [] best_text_chunks_matched: int | None = None @@ -429,6 +430,20 @@ class DefaultAgent(ConversationEntity): intent_context=intent_context, language=language, ): + # User intents have highest priority + if (result.intent_metadata is not None) and result.intent_metadata.get( + METADATA_CUSTOM_SENTENCE + ): + if (custom_result is None) or ( + result.text_chunks_matched > custom_result.text_chunks_matched + ): + custom_result = result + + # Clear builtin results + best_results = [] + name_result = None + continue + # Prioritize results with a "name" slot, but still prefer ones with # more literal text matched. if ( @@ -453,6 +468,10 @@ class DefaultAgent(ConversationEntity): # We will resolve the ambiguity below. best_results.append(result) + if custom_result is not None: + # Prioritize user intents + return custom_result + if name_result is not None: # Prioritize matches with entity names above area names return name_result @@ -718,11 +737,22 @@ class DefaultAgent(ConversationEntity): 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}]} + 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() } }, diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 48f227e9497..dc940dba81b 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1,12 +1,15 @@ """The tests for the Conversation component.""" from http import HTTPStatus +import os +import tempfile from typing import Any from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol +import yaml from homeassistant.components import conversation from homeassistant.components.conversation import default_agent @@ -1389,3 +1392,103 @@ async def test_ws_hass_agent_debug_sentence_trigger( # Trigger should not have been executed assert len(calls) == 0 + + +async def test_custom_sentences_priority( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, +) -> None: + """Test that user intents from custom_sentences have priority over builtin intents/sentences.""" + with tempfile.NamedTemporaryFile( + mode="w+", + encoding="utf-8", + suffix=".yaml", + dir=os.path.join(hass.config.config_dir, "custom_sentences", "en"), + ) as custom_sentences_file: + # Add a custom sentence that would match a builtin sentence. + # Custom sentences have priority. + yaml.dump( + { + "language": "en", + "intents": { + "CustomIntent": {"data": [{"sentences": ["turn on the lamp"]}]} + }, + }, + custom_sentences_file, + ) + custom_sentences_file.flush() + custom_sentences_file.seek(0) + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "light", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "CustomIntent": {"speech": {"text": "custom response"}} + } + }, + ) + + # Ensure that a "lamp" exists so that we can verify the custom intent + # overrides the builtin sentence. + hass.states.async_set("light.lamp", "off") + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", + json={ + "text": "turn on the lamp", + "language": hass.config.language, + }, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "custom response" + + +async def test_config_sentences_priority( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, +) -> None: + """Test that user intents from configuration.yaml have priority over builtin intents/sentences.""" + # Add a custom sentence that would match a builtin sentence. + # Custom sentences have priority. + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component( + hass, + "conversation", + {"conversation": {"intents": {"CustomIntent": ["turn on the lamp"]}}}, + ) + assert await async_setup_component(hass, "light", {}) + assert await async_setup_component( + hass, + "intent_script", + {"intent_script": {"CustomIntent": {"speech": {"text": "custom response"}}}}, + ) + + # Ensure that a "lamp" exists so that we can verify the custom intent + # overrides the builtin sentence. + hass.states.async_set("light.lamp", "off") + + client = await hass_client() + resp = await client.post( + "/api/conversation/process", + json={ + "text": "turn on the lamp", + "language": hass.config.language, + }, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "custom response"