Improved Assist debug (#108889)

* Differentiate builtin/custom sentences and triggers in debug

* Refactor so async_process runs triggers

* Report relative path of custom sentences file

* Add sentence trigger test
pull/108937/head
Michael Hansen 2024-01-26 22:04:45 -06:00 committed by GitHub
parent f96f4d31f7
commit 61c6c70a7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 275 additions and 71 deletions

View File

@ -31,7 +31,13 @@ from homeassistant.util import language as language_util
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
from .const import HOME_ASSISTANT_AGENT
from .default_agent import DefaultAgent, async_setup as async_setup_default_agent
from .default_agent import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
DefaultAgent,
SentenceTriggerResult,
async_setup as async_setup_default_agent,
)
__all__ = [
"DOMAIN",
@ -324,49 +330,64 @@ async def websocket_hass_agent_debug(
# Return results for each sentence in the same order as the input.
result_dicts: list[dict[str, Any] | None] = []
for result in results:
if result is None:
# Indicate that a recognition failure occurred
result_dicts.append(None)
continue
successful_match = not result.unmatched_entities
result_dict = {
# Name of the matching intent (or the closest)
"intent": {
"name": result.intent.name,
},
# Slot values that would be received by the intent
"slots": { # direct access to values
entity_key: entity.value
for entity_key, entity in result.entities.items()
},
# Extra slot details, such as the originally matched text
"details": {
entity_key: {
"name": entity.name,
"value": entity.value,
"text": entity.text,
}
for entity_key, entity in result.entities.items()
},
# Entities/areas/etc. that would be targeted
"targets": {},
# True if match was successful
"match": successful_match,
# Text of the sentence template that matched (or was closest)
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(result),
}
if successful_match:
result_dict["targets"] = {
state.entity_id: {"matched": is_matched}
for state, is_matched in _get_debug_targets(hass, result)
result_dict: dict[str, Any] | None = None
if isinstance(result, SentenceTriggerResult):
result_dict = {
# Matched a user-defined sentence trigger.
# We can't provide the response here without executing the
# trigger.
"match": True,
"source": "trigger",
"sentence_template": result.sentence_template or "",
}
elif isinstance(result, RecognizeResult):
successful_match = not result.unmatched_entities
result_dict = {
# Name of the matching intent (or the closest)
"intent": {
"name": result.intent.name,
},
# Slot values that would be received by the intent
"slots": { # direct access to values
entity_key: entity.value
for entity_key, entity in result.entities.items()
},
# Extra slot details, such as the originally matched text
"details": {
entity_key: {
"name": entity.name,
"value": entity.value,
"text": entity.text,
}
for entity_key, entity in result.entities.items()
},
# Entities/areas/etc. that would be targeted
"targets": {},
# True if match was successful
"match": successful_match,
# Text of the sentence template that matched (or was closest)
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(result),
}
if result.intent_sentence is not None:
result_dict["sentence_template"] = result.intent_sentence.text
if successful_match:
result_dict["targets"] = {
state.entity_id: {"matched": is_matched}
for state, is_matched in _get_debug_targets(hass, result)
}
if result.intent_sentence is not None:
result_dict["sentence_template"] = result.intent_sentence.text
# Inspect metadata to determine if this matched a custom sentence
if result.intent_metadata and result.intent_metadata.get(
METADATA_CUSTOM_SENTENCE
):
result_dict["source"] = "custom"
result_dict["file"] = result.intent_metadata.get(METADATA_CUSTOM_FILE)
else:
result_dict["source"] = "builtin"
result_dicts.append(result_dict)
@ -402,6 +423,16 @@ def _get_debug_targets(
# HassGetState only
state_names = set(cv.ensure_list(entities["state"].value))
if (
(name is None)
and (area_name is None)
and (not domains)
and (not device_classes)
and (not state_names)
):
# Avoid "matching" all entities when there is no filter
return
states = intent.async_match_states(
hass,
name=name,

View File

@ -62,6 +62,8 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
REGEX_TYPE = type(re.compile(""))
TRIGGER_CALLBACK_TYPE = Callable[[str, RecognizeResult], Awaitable[str | None]]
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
def json_load(fp: IO[str]) -> JsonObjectType:
@ -88,6 +90,15 @@ class TriggerData:
callback: TRIGGER_CALLBACK_TYPE
@dataclass(slots=True)
class SentenceTriggerResult:
"""Result when matching a sentence trigger in an automation."""
sentence: str
sentence_template: str | None
matched_triggers: dict[int, RecognizeResult]
def _get_language_variations(language: str) -> Iterable[str]:
"""Generate language codes with and without region."""
yield language
@ -177,8 +188,11 @@ class DefaultAgent(AbstractConversationAgent):
async def async_recognize(
self, user_input: ConversationInput
) -> RecognizeResult | None:
) -> RecognizeResult | SentenceTriggerResult | None:
"""Recognize intent from user input."""
if trigger_result := await self._match_triggers(user_input.text):
return trigger_result
language = user_input.language or self.hass.config.language
lang_intents = self._lang_intents.get(language)
@ -208,13 +222,36 @@ class DefaultAgent(AbstractConversationAgent):
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence."""
if trigger_result := await self._match_triggers(user_input.text):
return trigger_result
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
if isinstance(result, SentenceTriggerResult):
# Gather callback responses in parallel
trigger_responses = await asyncio.gather(
*(
self._trigger_sentences[trigger_id].callback(
result.sentence, trigger_result
)
for trigger_id, trigger_result in result.matched_triggers.items()
)
)
# Use last non-empty result as response
response_text: str | None = None
for trigger_response in trigger_responses:
response_text = response_text or trigger_response
# Convert to conversation result
response = intent.IntentResponse(language=language)
response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_speech(response_text or "")
return ConversationResult(response=response)
# Intent match or failure
lang_intents = self._lang_intents.get(language)
if result is None:
@ -561,6 +598,22 @@ class DefaultAgent(AbstractConversationAgent):
),
dict,
):
# Add metadata so we can identify custom sentences in the debugger
custom_intents_dict = custom_sentences_yaml.get(
"intents", {}
)
for intent_dict in custom_intents_dict.values():
intent_data_list = intent_dict.get("data", [])
for intent_data in intent_data_list:
sentence_metadata = intent_data.get("metadata", {})
sentence_metadata[METADATA_CUSTOM_SENTENCE] = True
sentence_metadata[METADATA_CUSTOM_FILE] = str(
custom_sentences_path.relative_to(
custom_sentences_dir.parent
)
)
intent_data["metadata"] = sentence_metadata
merge_dict(intents_dict, custom_sentences_yaml)
else:
_LOGGER.warning(
@ -807,11 +860,11 @@ class DefaultAgent(AbstractConversationAgent):
# Force rebuild on next use
self._trigger_intents = None
async def _match_triggers(self, sentence: str) -> ConversationResult | None:
async def _match_triggers(self, sentence: str) -> SentenceTriggerResult | None:
"""Try to match sentence against registered trigger sentences.
Calls the registered callbacks if there's a match and returns a positive
conversation result.
Calls the registered callbacks if there's a match and returns a sentence
trigger result.
"""
if not self._trigger_sentences:
# No triggers registered
@ -824,7 +877,11 @@ class DefaultAgent(AbstractConversationAgent):
assert self._trigger_intents is not None
matched_triggers: dict[int, RecognizeResult] = {}
matched_template: str | None = None
for result in recognize_all(sentence, self._trigger_intents):
if result.intent_sentence is not None:
matched_template = result.intent_sentence.text
trigger_id = int(result.intent.name)
if trigger_id in matched_triggers:
# Already matched a sentence from this trigger
@ -843,24 +900,7 @@ class DefaultAgent(AbstractConversationAgent):
list(matched_triggers),
)
# Gather callback responses in parallel
trigger_responses = await asyncio.gather(
*(
self._trigger_sentences[trigger_id].callback(sentence, result)
for trigger_id, result in matched_triggers.items()
)
)
# Use last non-empty result as speech response
speech: str | None = None
for trigger_response in trigger_responses:
speech = speech or trigger_response
response = intent.IntentResponse(language=self.hass.config.language)
response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_speech(speech or "")
return ConversationResult(response=response)
return SentenceTriggerResult(sentence, matched_template, matched_triggers)
def _make_error_result(

View File

@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["hassil==1.5.3", "home-assistant-intents==2024.1.2"]
"requirements": ["hassil==1.6.0", "home-assistant-intents==2024.1.2"]
}

View File

@ -26,7 +26,7 @@ ha-av==10.1.1
ha-ffmpeg==3.1.0
habluetooth==2.4.0
hass-nabucasa==0.75.1
hassil==1.5.3
hassil==1.6.0
home-assistant-bluetooth==1.12.0
home-assistant-frontend==20240112.0
home-assistant-intents==2024.1.2

View File

@ -1019,7 +1019,7 @@ hass-nabucasa==0.75.1
hass-splunk==0.1.1
# homeassistant.components.conversation
hassil==1.5.3
hassil==1.6.0
# homeassistant.components.jewish_calendar
hdate==0.10.4

View File

@ -821,7 +821,7 @@ habluetooth==2.4.0
hass-nabucasa==0.75.1
# homeassistant.components.conversation
hassil==1.5.3
hassil==1.6.0
# homeassistant.components.jewish_calendar
hdate==0.10.4

View File

@ -1408,6 +1408,7 @@
'slots': dict({
'name': 'my cool light',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': True,
@ -1432,6 +1433,7 @@
'slots': dict({
'name': 'my cool light',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': True,
@ -1462,6 +1464,7 @@
'area': 'kitchen',
'domain': 'light',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': True,
@ -1498,6 +1501,7 @@
'domain': 'light',
'state': 'on',
}),
'source': 'builtin',
'targets': dict({
'light.kitchen': dict({
'matched': False,
@ -1522,6 +1526,7 @@
'slots': dict({
'domain': 'scene',
}),
'source': 'builtin',
'targets': dict({
}),
'unmatched_slots': dict({
@ -1540,6 +1545,35 @@
}),
})
# ---
# name: test_ws_hass_agent_debug_custom_sentence
dict({
'results': list([
dict({
'details': dict({
'beer_style': dict({
'name': 'beer_style',
'text': 'lager',
'value': 'lager',
}),
}),
'file': 'en/beer.yaml',
'intent': dict({
'name': 'OrderBeer',
}),
'match': True,
'sentence_template': "I'd like to order a {beer_style} [please]",
'slots': dict({
'beer_style': 'lager',
}),
'source': 'custom',
'targets': dict({
}),
'unmatched_slots': dict({
}),
}),
]),
})
# ---
# name: test_ws_hass_agent_debug_null_result
dict({
'results': list([
@ -1572,6 +1606,7 @@
'brightness': 100,
'name': 'test light',
}),
'source': 'builtin',
'targets': dict({
'light.demo_1234': dict({
'matched': True,
@ -1602,6 +1637,7 @@
'slots': dict({
'name': 'test light',
}),
'source': 'builtin',
'targets': dict({
}),
'unmatched_slots': dict({
@ -1611,3 +1647,14 @@
]),
})
# ---
# name: test_ws_hass_agent_debug_sentence_trigger
dict({
'results': list([
dict({
'match': True,
'sentence_template': 'hello[ world]',
'source': 'trigger',
}),
]),
})
# ---

View File

@ -1286,3 +1286,89 @@ async def test_ws_hass_agent_debug_out_of_range(
# Name matched, but brightness didn't
assert results[0]["slots"] == {"name": "test light"}
assert results[0]["unmatched_slots"] == {"brightness": 1001}
async def test_ws_hass_agent_debug_custom_sentence(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test homeassistant agent debug websocket command with a custom sentence."""
# Expecting testing_config/custom_sentences/en/beer.yaml
intent.async_register(hass, OrderBeerIntentHandler())
client = await hass_ws_client(hass)
# Brightness is in range (0-100)
await client.send_json_auto_id(
{
"type": "conversation/agent/homeassistant/debug",
"sentences": [
"I'd like to order a lager, please.",
],
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == snapshot
debug_results = msg["result"].get("results", [])
assert len(debug_results) == 1
assert debug_results[0].get("match")
assert debug_results[0].get("source") == "custom"
assert debug_results[0].get("file") == "en/beer.yaml"
async def test_ws_hass_agent_debug_sentence_trigger(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test homeassistant agent debug websocket command with a sentence trigger."""
calls = async_mock_service(hass, "test", "automation")
assert await async_setup_component(
hass,
"automation",
{
"automation": {
"trigger": {
"platform": "conversation",
"command": ["hello", "hello[ world]"],
},
"action": {
"service": "test.automation",
"data_template": {"data": "{{ trigger }}"},
},
}
},
)
client = await hass_ws_client(hass)
# Use trigger sentence
await client.send_json_auto_id(
{
"type": "conversation/agent/homeassistant/debug",
"sentences": ["hello world"],
}
)
await hass.async_block_till_done()
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == snapshot
debug_results = msg["result"].get("results", [])
assert len(debug_results) == 1
assert debug_results[0].get("match")
assert debug_results[0].get("source") == "trigger"
assert debug_results[0].get("sentence_template") == "hello[ world]"
# Trigger should not have been executed
assert len(calls) == 0