mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 14:29:21 -04:00
Give intent match failures a message instead of a repr (#182343)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9dccac7fa5
commit
fe11885cae
@@ -8,7 +8,7 @@ from enum import Enum, auto
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import IO, Any, cast, override
|
||||
from typing import IO, Any, NamedTuple, assert_never, cast, override
|
||||
|
||||
from gazetteer_matcher import FrameCandidate, GazetteerMatcher
|
||||
from hassil.expression import Expression, Group, ListReference
|
||||
@@ -642,7 +642,7 @@ class DefaultAgent(ConversationEntity):
|
||||
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
|
||||
match_error
|
||||
)
|
||||
intent_response = _make_error_result(
|
||||
language,
|
||||
@@ -1444,8 +1444,12 @@ class DefaultAgent(ConversationEntity):
|
||||
else:
|
||||
response_key = error_key
|
||||
|
||||
# A new error reaches here before every language has translated it, so
|
||||
# prefer the language's own generic error over the English default
|
||||
response_str = (
|
||||
lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT
|
||||
lang_intents.error_responses.get(response_key)
|
||||
or lang_intents.error_responses.get(ErrorKey.NO_INTENT.value)
|
||||
or _DEFAULT_ERROR_TEXT
|
||||
)
|
||||
response_template = template.Template(response_str, self.hass)
|
||||
|
||||
@@ -1738,132 +1742,165 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str
|
||||
return ErrorKey.NO_INTENT, {}
|
||||
|
||||
|
||||
def _get_match_error_response(
|
||||
hass: HomeAssistant,
|
||||
match_error: intent.MatchFailedError,
|
||||
) -> tuple[ErrorKey, dict[str, Any]]:
|
||||
"""Return key and template arguments for error when target matching fails."""
|
||||
# Map to errors in home-assistant-intents
|
||||
_NO_TARGET_ERRORS: dict[tuple[str, str, bool], ErrorKey] = {
|
||||
("entity", "", False): ErrorKey.NO_ENTITY,
|
||||
("entity", "", True): ErrorKey.NO_ENTITY_EXPOSED,
|
||||
("entity", "area", False): ErrorKey.NO_ENTITY_IN_AREA,
|
||||
("entity", "area", True): ErrorKey.NO_ENTITY_IN_AREA_EXPOSED,
|
||||
("entity", "floor", False): ErrorKey.NO_ENTITY_IN_FLOOR,
|
||||
("entity", "floor", True): ErrorKey.NO_ENTITY_IN_FLOOR_EXPOSED,
|
||||
("device_class", "", False): ErrorKey.NO_DEVICE_CLASS,
|
||||
("device_class", "", True): ErrorKey.NO_DEVICE_CLASS_EXPOSED,
|
||||
("device_class", "area", False): ErrorKey.NO_DEVICE_CLASS_IN_AREA,
|
||||
("device_class", "area", True): ErrorKey.NO_DEVICE_CLASS_IN_AREA_EXPOSED,
|
||||
("device_class", "floor", False): ErrorKey.NO_DEVICE_CLASS_IN_FLOOR,
|
||||
("device_class", "floor", True): ErrorKey.NO_DEVICE_CLASS_IN_FLOOR_EXPOSED,
|
||||
("domain", "", False): ErrorKey.NO_DOMAIN,
|
||||
("domain", "", True): ErrorKey.NO_DOMAIN_EXPOSED,
|
||||
("domain", "area", False): ErrorKey.NO_DOMAIN_IN_AREA,
|
||||
("domain", "area", True): ErrorKey.NO_DOMAIN_IN_AREA_EXPOSED,
|
||||
("domain", "floor", False): ErrorKey.NO_DOMAIN_IN_FLOOR,
|
||||
("domain", "floor", True): ErrorKey.NO_DOMAIN_IN_FLOOR_EXPOSED,
|
||||
}
|
||||
|
||||
|
||||
class _NoTargetOrder(NamedTuple):
|
||||
"""Which constraints an error may name, most specific first."""
|
||||
|
||||
subjects: tuple[str, ...]
|
||||
"""What was asked for."""
|
||||
|
||||
scopes: tuple[str, ...]
|
||||
"""Where it was asked for."""
|
||||
|
||||
|
||||
# The failing constraint is what the message should be about
|
||||
_ANY_TARGET = ("entity", "device_class", "domain")
|
||||
_ANY_SCOPE = ("area", "floor")
|
||||
_NO_TARGET_ORDER: dict[intent.MatchFailedReason, _NoTargetOrder] = {
|
||||
intent.MatchFailedReason.NAME: _NoTargetOrder(("entity",), _ANY_SCOPE),
|
||||
intent.MatchFailedReason.AREA: _NoTargetOrder(_ANY_TARGET, ("area",)),
|
||||
intent.MatchFailedReason.FLOOR: _NoTargetOrder(_ANY_TARGET, ("floor",)),
|
||||
intent.MatchFailedReason.DOMAIN: _NoTargetOrder(
|
||||
("device_class", "domain"), _ANY_SCOPE
|
||||
),
|
||||
intent.MatchFailedReason.DEVICE_CLASS: _NoTargetOrder(
|
||||
("device_class", "domain"), _ANY_SCOPE
|
||||
),
|
||||
intent.MatchFailedReason.ASSISTANT: _NoTargetOrder(_ANY_TARGET, _ANY_SCOPE),
|
||||
}
|
||||
|
||||
|
||||
def _get_no_target_response(
|
||||
constraints: intent.MatchTargetsConstraints,
|
||||
order: _NoTargetOrder,
|
||||
*,
|
||||
exposed_only: bool,
|
||||
) -> tuple[ErrorKey, dict[str, Any]]:
|
||||
"""Return the error naming the failed constraint, scoped to an area or floor."""
|
||||
subjects: dict[str, dict[str, Any]] = {}
|
||||
if constraints.name:
|
||||
subjects["entity"] = {"entity": constraints.name}
|
||||
if constraints.device_classes:
|
||||
subjects["device_class"] = {
|
||||
"device_class": next(iter(constraints.device_classes))
|
||||
}
|
||||
if constraints.domains:
|
||||
subjects["domain"] = {"domain": next(iter(constraints.domains))}
|
||||
|
||||
for kind in order.subjects:
|
||||
if (args := subjects.get(kind)) is not None:
|
||||
break
|
||||
else:
|
||||
# The constraint that failed was not set, so there is nothing to name
|
||||
return ErrorKey.NO_INTENT, {}
|
||||
|
||||
scopes = {"area": constraints.area_name, "floor": constraints.floor_name}
|
||||
scope = ""
|
||||
for candidate in order.scopes:
|
||||
if scopes[candidate]:
|
||||
scope = candidate
|
||||
args[candidate] = scopes[candidate]
|
||||
break
|
||||
|
||||
return _NO_TARGET_ERRORS[kind, scope, exposed_only], args
|
||||
|
||||
|
||||
# Present in intents, but not the ErrorKey enum yet.
|
||||
_DUPLICATE_TARGETS = "duplicate_targets"
|
||||
|
||||
|
||||
def _get_duplicate_response(
|
||||
name: str | None, constraints: intent.MatchTargetsConstraints
|
||||
) -> tuple[ErrorKey | str, dict[str, Any]]:
|
||||
"""Return the error for a match ambiguous between more entities than can be used."""
|
||||
if not name:
|
||||
# Nothing was named, so all the answer can say is that several matched
|
||||
return _DUPLICATE_TARGETS, {}
|
||||
|
||||
if constraints.area_name:
|
||||
return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, {
|
||||
"entity": name,
|
||||
"area": constraints.area_name,
|
||||
}
|
||||
|
||||
if constraints.floor_name:
|
||||
return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, {
|
||||
"entity": name,
|
||||
"floor": constraints.floor_name,
|
||||
}
|
||||
|
||||
return ErrorKey.DUPLICATE_ENTITIES, {"entity": name}
|
||||
|
||||
|
||||
def _get_match_error_response(
|
||||
match_error: intent.MatchFailedError,
|
||||
) -> tuple[ErrorKey | str, dict[str, Any]]:
|
||||
"""Return key and template arguments for error when target matching fails."""
|
||||
constraints, result = match_error.constraints, match_error.result
|
||||
reason = result.no_match_reason
|
||||
if reason is None:
|
||||
return ErrorKey.NO_INTENT, {}
|
||||
|
||||
if (
|
||||
reason
|
||||
in (intent.MatchFailedReason.DEVICE_CLASS, intent.MatchFailedReason.DOMAIN)
|
||||
) and constraints.device_classes:
|
||||
device_class = next(iter(constraints.device_classes)) # first device class
|
||||
if constraints.area_name:
|
||||
# device_class in area
|
||||
return ErrorKey.NO_DEVICE_CLASS_IN_AREA, {
|
||||
"device_class": device_class,
|
||||
"area": constraints.area_name,
|
||||
match reason:
|
||||
case (
|
||||
intent.MatchFailedReason.NAME
|
||||
| intent.MatchFailedReason.AREA
|
||||
| intent.MatchFailedReason.FLOOR
|
||||
| intent.MatchFailedReason.DOMAIN
|
||||
| intent.MatchFailedReason.DEVICE_CLASS
|
||||
| intent.MatchFailedReason.ASSISTANT
|
||||
):
|
||||
return _get_no_target_response(
|
||||
constraints,
|
||||
_NO_TARGET_ORDER[reason],
|
||||
exposed_only=reason is intent.MatchFailedReason.ASSISTANT,
|
||||
)
|
||||
|
||||
case intent.MatchFailedReason.INVALID_AREA:
|
||||
return ErrorKey.NO_AREA, {"area": result.no_match_name}
|
||||
|
||||
case intent.MatchFailedReason.INVALID_FLOOR:
|
||||
return ErrorKey.NO_FLOOR, {"floor": result.no_match_name}
|
||||
|
||||
case intent.MatchFailedReason.FEATURE:
|
||||
return ErrorKey.FEATURE_NOT_SUPPORTED, {}
|
||||
|
||||
case intent.MatchFailedReason.STATE:
|
||||
if not constraints.states:
|
||||
return ErrorKey.NO_INTENT, {}
|
||||
return ErrorKey.ENTITY_WRONG_STATE, {
|
||||
"state": next(iter(constraints.states))
|
||||
}
|
||||
|
||||
# device_class only
|
||||
return ErrorKey.NO_DEVICE_CLASS, {"device_class": device_class}
|
||||
case intent.MatchFailedReason.DUPLICATE_NAME:
|
||||
return _get_duplicate_response(result.no_match_name, constraints)
|
||||
|
||||
if (reason is intent.MatchFailedReason.DOMAIN) and constraints.domains:
|
||||
domain = next(iter(constraints.domains)) # first domain
|
||||
if constraints.area_name:
|
||||
# domain in area
|
||||
return ErrorKey.NO_DOMAIN_IN_AREA, {
|
||||
"domain": domain,
|
||||
"area": constraints.area_name,
|
||||
}
|
||||
case intent.MatchFailedReason.MULTIPLE_TARGETS:
|
||||
return _get_duplicate_response(constraints.name, constraints)
|
||||
|
||||
if constraints.floor_name:
|
||||
# domain in floor
|
||||
return ErrorKey.NO_DOMAIN_IN_FLOOR, {
|
||||
"domain": domain,
|
||||
"floor": constraints.floor_name,
|
||||
}
|
||||
|
||||
# domain only
|
||||
return ErrorKey.NO_DOMAIN, {"domain": domain}
|
||||
|
||||
if reason is intent.MatchFailedReason.DUPLICATE_NAME:
|
||||
if constraints.floor_name:
|
||||
# duplicate on floor
|
||||
return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, {
|
||||
"entity": result.no_match_name,
|
||||
"floor": constraints.floor_name,
|
||||
}
|
||||
|
||||
if constraints.area_name:
|
||||
# duplicate on area
|
||||
return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, {
|
||||
"entity": result.no_match_name,
|
||||
"area": constraints.area_name,
|
||||
}
|
||||
|
||||
return ErrorKey.DUPLICATE_ENTITIES, {"entity": result.no_match_name}
|
||||
|
||||
if reason is intent.MatchFailedReason.INVALID_AREA:
|
||||
# Invalid area name
|
||||
return ErrorKey.NO_AREA, {"area": result.no_match_name}
|
||||
|
||||
if reason is intent.MatchFailedReason.INVALID_FLOOR:
|
||||
# Invalid floor name
|
||||
return ErrorKey.NO_FLOOR, {"floor": result.no_match_name}
|
||||
|
||||
if reason is intent.MatchFailedReason.FEATURE:
|
||||
# Feature not supported by entity
|
||||
return ErrorKey.FEATURE_NOT_SUPPORTED, {}
|
||||
|
||||
if reason is intent.MatchFailedReason.STATE:
|
||||
# Entity is not in correct state
|
||||
assert constraints.states
|
||||
state = next(iter(constraints.states))
|
||||
|
||||
return ErrorKey.ENTITY_WRONG_STATE, {"state": state}
|
||||
|
||||
if reason is intent.MatchFailedReason.ASSISTANT:
|
||||
# Not exposed
|
||||
if constraints.name:
|
||||
if constraints.area_name:
|
||||
return ErrorKey.NO_ENTITY_IN_AREA_EXPOSED, {
|
||||
"entity": constraints.name,
|
||||
"area": constraints.area_name,
|
||||
}
|
||||
if constraints.floor_name:
|
||||
return ErrorKey.NO_ENTITY_IN_FLOOR_EXPOSED, {
|
||||
"entity": constraints.name,
|
||||
"floor": constraints.floor_name,
|
||||
}
|
||||
return ErrorKey.NO_ENTITY_EXPOSED, {"entity": constraints.name}
|
||||
|
||||
if constraints.device_classes:
|
||||
device_class = next(iter(constraints.device_classes))
|
||||
|
||||
if constraints.area_name:
|
||||
return ErrorKey.NO_DEVICE_CLASS_IN_AREA_EXPOSED, {
|
||||
"device_class": device_class,
|
||||
"area": constraints.area_name,
|
||||
}
|
||||
if constraints.floor_name:
|
||||
return ErrorKey.NO_DEVICE_CLASS_IN_FLOOR_EXPOSED, {
|
||||
"device_class": device_class,
|
||||
"floor": constraints.floor_name,
|
||||
}
|
||||
return ErrorKey.NO_DEVICE_CLASS_EXPOSED, {"device_class": device_class}
|
||||
|
||||
if constraints.domains:
|
||||
domain = next(iter(constraints.domains))
|
||||
|
||||
if constraints.area_name:
|
||||
return ErrorKey.NO_DOMAIN_IN_AREA_EXPOSED, {
|
||||
"domain": domain,
|
||||
"area": constraints.area_name,
|
||||
}
|
||||
if constraints.floor_name:
|
||||
return ErrorKey.NO_DOMAIN_IN_FLOOR_EXPOSED, {
|
||||
"domain": domain,
|
||||
"floor": constraints.floor_name,
|
||||
}
|
||||
return ErrorKey.NO_DOMAIN_EXPOSED, {"domain": domain}
|
||||
|
||||
# Default error
|
||||
return ErrorKey.NO_INTENT, {}
|
||||
assert_never(reason)
|
||||
|
||||
|
||||
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
|
||||
|
||||
@@ -192,7 +192,12 @@ class MatchFailedError(IntentError):
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
"""Return string representation."""
|
||||
"""Return why matching failed."""
|
||||
return _match_failure_message(self.result, self.constraints)
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
"""Return the full result and constraints, for logs and debugging."""
|
||||
return (
|
||||
f"<MatchFailedError result={self.result},"
|
||||
f" constraints={self.constraints},"
|
||||
@@ -351,6 +356,65 @@ class MatchTargetsConstraints:
|
||||
)
|
||||
|
||||
|
||||
_MATCH_FAILURE_REASONS: dict[MatchFailedReason, str] = {
|
||||
MatchFailedReason.NAME: "No entities matched the name",
|
||||
MatchFailedReason.AREA: "No entities were in the area",
|
||||
MatchFailedReason.FLOOR: "No entities were on the floor",
|
||||
MatchFailedReason.DOMAIN: "No entities matched the domain",
|
||||
MatchFailedReason.DEVICE_CLASS: "No entities matched the device class",
|
||||
MatchFailedReason.FEATURE: "No entities supported the required features",
|
||||
MatchFailedReason.STATE: "No entities were in the required state",
|
||||
MatchFailedReason.ASSISTANT: "No matching entities are exposed to the assistant",
|
||||
MatchFailedReason.INVALID_AREA: "The area does not exist",
|
||||
MatchFailedReason.INVALID_FLOOR: "The floor does not exist",
|
||||
MatchFailedReason.DUPLICATE_NAME: ("Multiple entities share the name"),
|
||||
MatchFailedReason.MULTIPLE_TARGETS: (
|
||||
"Multiple entities matched, but a single target is required"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _describe_constraints(constraints: MatchTargetsConstraints) -> str:
|
||||
"""List the constraints that were set, in an order that does not vary."""
|
||||
described: list[str] = []
|
||||
|
||||
for label, value in (
|
||||
("name", constraints.name),
|
||||
("area", constraints.area_name),
|
||||
("floor", constraints.floor_name),
|
||||
):
|
||||
if value:
|
||||
described.append(f"{label} {value!r}")
|
||||
|
||||
# Sorted because a set's iteration order is not stable between runs
|
||||
for label, values in (
|
||||
("domains", constraints.domains),
|
||||
("device classes", constraints.device_classes),
|
||||
("states", constraints.states),
|
||||
):
|
||||
if values:
|
||||
described.append(f"{label} {', '.join(sorted(values))}")
|
||||
|
||||
if constraints.features:
|
||||
described.append(f"features {constraints.features}")
|
||||
|
||||
return ", ".join(described)
|
||||
|
||||
|
||||
def _match_failure_message(
|
||||
result: MatchTargetsResult, constraints: MatchTargetsConstraints
|
||||
) -> str:
|
||||
"""Describe a failed match as a sentence naming the constraints in play."""
|
||||
message = "No entities matched"
|
||||
if (reason := result.no_match_reason) is not None:
|
||||
message = _MATCH_FAILURE_REASONS.get(reason, message)
|
||||
|
||||
if described := _describe_constraints(constraints):
|
||||
return f"{message} (given {described})"
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchTargetsPreferences:
|
||||
"""Preferences to disambiguate duplicate name matches."""
|
||||
|
||||
@@ -6,12 +6,15 @@ import tempfile
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from hassil.intents import Intents
|
||||
from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult
|
||||
from home_assistant_intents import ErrorKey
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
import yaml
|
||||
|
||||
from homeassistant.components import conversation, cover, media_player, weather
|
||||
from homeassistant.components.climate import ClimateEntityFeature
|
||||
from homeassistant.components.conversation import (
|
||||
DOMAIN,
|
||||
async_get_agent,
|
||||
@@ -24,7 +27,11 @@ from homeassistant.components.conversation.chat_log import (
|
||||
async_get_chat_log,
|
||||
)
|
||||
from homeassistant.components.conversation.const import ATTR_LANGUAGE, ATTR_TEXT
|
||||
from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE
|
||||
from homeassistant.components.conversation.default_agent import (
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
LanguageIntents,
|
||||
_get_match_error_response,
|
||||
)
|
||||
from homeassistant.components.conversation.models import ConversationInput
|
||||
from homeassistant.components.cover import SERVICE_OPEN_COVER
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
@@ -39,6 +46,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
STATE_CLOSED,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
@@ -993,6 +1001,237 @@ async def test_error_no_device_on_floor_exposed(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_error_device_in_other_area(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
area_registry: ar.AreaRegistry,
|
||||
) -> None:
|
||||
"""Test error message when a known device exists, but not in the asked-for area."""
|
||||
area_kitchen = area_registry.async_update(
|
||||
area_registry.async_get_or_create("kitchen_id").id, name="kitchen"
|
||||
)
|
||||
area_bedroom = area_registry.async_update(
|
||||
area_registry.async_get_or_create("bedroom_id").id, name="bedroom"
|
||||
)
|
||||
|
||||
# The kitchen has a light, so the area itself is a valid target
|
||||
for object_id, name, area in (
|
||||
("1234", "ceiling", area_kitchen),
|
||||
("5678", "test light", area_bedroom),
|
||||
):
|
||||
entry = entity_registry.async_get_or_create("light", "demo", object_id)
|
||||
entry = entity_registry.async_update_entity(
|
||||
entry.entity_id, name=name, area_id=area.id
|
||||
)
|
||||
hass.states.async_set(entry.entity_id, "off", {ATTR_FRIENDLY_NAME: name})
|
||||
expose_entity(hass, entry.entity_id, True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass, "turn on the test light in the kitchen", None, Context(), None
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "Sorry, I am not aware of any device called test light in the kitchen area"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reason", list(intent.MatchFailedReason))
|
||||
def test_match_error_response_never_falls_back(
|
||||
reason: intent.MatchFailedReason,
|
||||
) -> None:
|
||||
"""Test every failure keeps its own message when the constraint was set.
|
||||
|
||||
A reason with no branch used to reach the generic "couldn't understand"
|
||||
error, which says nothing about what was actually wrong.
|
||||
"""
|
||||
match_error = intent.MatchFailedError(
|
||||
result=intent.MatchTargetsResult(False, reason, no_match_name="test light"),
|
||||
constraints=intent.MatchTargetsConstraints(
|
||||
name="test light",
|
||||
area_name="kitchen",
|
||||
domains={"light"},
|
||||
device_classes={"garage"},
|
||||
states={"on"},
|
||||
),
|
||||
)
|
||||
|
||||
error_key, _error_args = _get_match_error_response(match_error)
|
||||
|
||||
assert error_key is not ErrorKey.NO_INTENT
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reason", "expected_key", "expected_args"),
|
||||
[
|
||||
pytest.param(
|
||||
intent.MatchFailedReason.NAME,
|
||||
ErrorKey.NO_ENTITY,
|
||||
{"entity": "test light"},
|
||||
id="name",
|
||||
),
|
||||
pytest.param(
|
||||
intent.MatchFailedReason.DOMAIN,
|
||||
ErrorKey.NO_DOMAIN,
|
||||
{"domain": "light"},
|
||||
id="domain",
|
||||
),
|
||||
pytest.param(
|
||||
intent.MatchFailedReason.ASSISTANT,
|
||||
ErrorKey.NO_ENTITY_EXPOSED,
|
||||
{"entity": "test light"},
|
||||
id="assistant",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_match_error_response_names_the_failed_constraint(
|
||||
reason: intent.MatchFailedReason,
|
||||
expected_key: ErrorKey,
|
||||
expected_args: dict[str, str],
|
||||
) -> None:
|
||||
"""Test the reason picks the subject, not whichever constraint was set first.
|
||||
|
||||
A domain failure alongside a name means no entity of that domain exists, so
|
||||
saying nothing is called that name would be wrong.
|
||||
"""
|
||||
match_error = intent.MatchFailedError(
|
||||
result=intent.MatchTargetsResult(False, reason),
|
||||
constraints=intent.MatchTargetsConstraints(
|
||||
name="test light", domains={"light"}
|
||||
),
|
||||
)
|
||||
|
||||
assert _get_match_error_response(match_error) == (expected_key, expected_args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reason", "expected_key", "expected_args"),
|
||||
[
|
||||
pytest.param(
|
||||
intent.MatchFailedReason.FLOOR,
|
||||
ErrorKey.NO_ENTITY_IN_FLOOR,
|
||||
{"entity": "test light", "floor": "ground"},
|
||||
id="floor",
|
||||
),
|
||||
pytest.param(
|
||||
intent.MatchFailedReason.AREA,
|
||||
ErrorKey.NO_ENTITY_IN_AREA,
|
||||
{"entity": "test light", "area": "kitchen"},
|
||||
id="area",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_match_error_response_names_the_failed_scope(
|
||||
reason: intent.MatchFailedReason,
|
||||
expected_key: ErrorKey,
|
||||
expected_args: dict[str, str],
|
||||
) -> None:
|
||||
"""Test the reason picks the scope when both an area and a floor were asked for.
|
||||
|
||||
Floors are filtered before areas, so a floor failure says nothing about
|
||||
whether the area would have matched.
|
||||
"""
|
||||
match_error = intent.MatchFailedError(
|
||||
result=intent.MatchTargetsResult(False, reason),
|
||||
constraints=intent.MatchTargetsConstraints(
|
||||
name="test light", area_name="kitchen", floor_name="ground"
|
||||
),
|
||||
)
|
||||
|
||||
assert _get_match_error_response(match_error) == (expected_key, expected_args)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_error_multiple_targets_without_name_is_spoken(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
area_registry: ar.AreaRegistry,
|
||||
) -> None:
|
||||
"""Test an ambiguous match with nothing named speaks a real response.
|
||||
|
||||
duplicate_targets is not an ErrorKey, so this asserts the rendered speech to
|
||||
catch the key failing to resolve and falling back to the generic error.
|
||||
"""
|
||||
area_kitchen = area_registry.async_update(
|
||||
area_registry.async_get_or_create("kitchen_id").id, name="kitchen"
|
||||
)
|
||||
for object_id, name in (("1234", "Thermostat One"), ("5678", "Thermostat Two")):
|
||||
entry = entity_registry.async_get_or_create("climate", "demo", object_id)
|
||||
entry = entity_registry.async_update_entity(
|
||||
entry.entity_id, name=name, area_id=area_kitchen.id
|
||||
)
|
||||
hass.states.async_set(
|
||||
entry.entity_id,
|
||||
"heat",
|
||||
{
|
||||
ATTR_FRIENDLY_NAME: name,
|
||||
"current_temperature": 20,
|
||||
ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE,
|
||||
},
|
||||
)
|
||||
expose_entity(hass, entry.entity_id, True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass, "what is the temperature in the kitchen", None, Context(), None
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "Sorry, more than one device matched your request"
|
||||
)
|
||||
|
||||
|
||||
def test_match_error_response_multiple_targets_without_name() -> None:
|
||||
"""Test an ambiguous match with nothing named still reports the ambiguity.
|
||||
|
||||
Several single_target callers constrain only a domain or device class, so
|
||||
there is no name to say which one was meant.
|
||||
"""
|
||||
match_error = intent.MatchFailedError(
|
||||
result=intent.MatchTargetsResult(
|
||||
False, intent.MatchFailedReason.MULTIPLE_TARGETS
|
||||
),
|
||||
constraints=intent.MatchTargetsConstraints(
|
||||
area_name="kitchen", domains={"climate"}
|
||||
),
|
||||
)
|
||||
|
||||
assert _get_match_error_response(match_error) == ("duplicate_targets", {})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_error_text_prefers_the_language_over_english(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test a language that translates only some errors still answers in itself.
|
||||
|
||||
Most languages translate a subset of the errors, and a missing one used to
|
||||
fall through to English no matter what language was being spoken.
|
||||
"""
|
||||
generic = "Ho sento, no entenc això"
|
||||
lang_intents = LanguageIntents(
|
||||
intents=Intents.from_dict({"language": "ca", "intents": {}}),
|
||||
intents_dict={},
|
||||
intent_responses={},
|
||||
error_responses={ErrorKey.NO_INTENT.value: generic},
|
||||
language_variant="ca",
|
||||
)
|
||||
agent = conversation.async_get_agent(hass)
|
||||
|
||||
assert (
|
||||
agent._get_error_text(
|
||||
ErrorKey.NO_ENTITY_EXPOSED, lang_intents, entity="test light"
|
||||
)
|
||||
== generic
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_error_no_device_in_area_exposed(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -177,7 +177,10 @@ async def test_http_handle_intent_match_failure(
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
|
||||
assert "DUPLICATE_NAME" in data["speech"]["plain"]["speech"]
|
||||
# The API speaks the failure, so it must be a sentence and not a repr
|
||||
assert data["speech"]["plain"]["speech"] == (
|
||||
"Multiple entities share the name (given name 'Garage Door')"
|
||||
)
|
||||
|
||||
|
||||
async def test_http_assistant(
|
||||
|
||||
@@ -1118,3 +1118,51 @@ async def test_intent_response_dict() -> None:
|
||||
|
||||
# The original dict should not be affected by the mutations
|
||||
assert response_dict1 == response_dict2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reason", list(intent.MatchFailedReason))
|
||||
def test_match_failed_error_describes_every_reason(
|
||||
reason: intent.MatchFailedReason,
|
||||
) -> None:
|
||||
"""Test every reason has wording, since str() is spoken by the REST API."""
|
||||
error = intent.MatchFailedError(
|
||||
result=intent.MatchTargetsResult(False, reason),
|
||||
constraints=intent.MatchTargetsConstraints(name="Lamp"),
|
||||
)
|
||||
|
||||
assert str(error) == f"{intent._MATCH_FAILURE_REASONS[reason]} (given name 'Lamp')"
|
||||
|
||||
|
||||
def test_match_failed_error_str_names_the_constraints() -> None:
|
||||
"""Test the message names what was asked for, and only what was set."""
|
||||
error = intent.MatchFailedError(
|
||||
result=intent.MatchTargetsResult(False, intent.MatchFailedReason.AREA),
|
||||
constraints=intent.MatchTargetsConstraints(
|
||||
name="Lamp", area_name="Kitchen", assistant="conversation"
|
||||
),
|
||||
)
|
||||
|
||||
assert (
|
||||
str(error) == "No entities were in the area (given name 'Lamp', area 'Kitchen')"
|
||||
)
|
||||
|
||||
|
||||
def test_match_failed_error_str_without_constraints() -> None:
|
||||
"""Test the message stands alone when nothing was constrained."""
|
||||
error = intent.MatchFailedError(
|
||||
result=intent.MatchTargetsResult(False, intent.MatchFailedReason.DOMAIN),
|
||||
constraints=intent.MatchTargetsConstraints(),
|
||||
)
|
||||
|
||||
assert str(error) == "No entities matched the domain"
|
||||
|
||||
|
||||
def test_match_failed_error_repr_keeps_the_detail() -> None:
|
||||
"""Test the full state is still available for logs and debugging."""
|
||||
error = intent.MatchFailedError(
|
||||
result=intent.MatchTargetsResult(False, intent.MatchFailedReason.NAME),
|
||||
constraints=intent.MatchTargetsConstraints(name="Lamp"),
|
||||
)
|
||||
|
||||
assert repr(error).startswith("<MatchFailedError result=MatchTargetsResult(")
|
||||
assert "constraints=MatchTargetsConstraints(" in repr(error)
|
||||
|
||||
Reference in New Issue
Block a user