mirror of
https://github.com/home-assistant/core.git
synced 2025-04-22 16:27:56 +00:00
Hassil intents (#85156)
* Add hassil to requirements * Add intent sentences * Update sentences * Use hassil to recognize intents in conversation * Fix tests * Bump hassil due to dependency conflict * Add dataclasses-json package contraints * Bump hassil (removes dataclasses-json dependency) * Remove climate sentences until intents are supported * Move I/O outside event loop * Bump hassil to 0.2.3 * Fix light tests * Handle areas in intents * Clean up code according to suggestions * Remove sentences from repo * Use home-assistant-intents package * Apply suggestions from code review * Flake8 Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
parent
3a905f80df
commit
ecaec0332d
@ -1,36 +1,26 @@
|
||||
"""Standard conversation implementation for Home Assistant."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hassil.intents import Intents, SlotList, TextSlotList
|
||||
from hassil.recognize import recognize
|
||||
from hassil.util import merge_dict
|
||||
from home_assistant_intents import get_intents
|
||||
|
||||
from homeassistant import core, setup
|
||||
from homeassistant.components.cover.intent import INTENT_CLOSE_COVER, INTENT_OPEN_COVER
|
||||
from homeassistant.components.shopping_list.intent import (
|
||||
INTENT_ADD_ITEM,
|
||||
INTENT_LAST_ITEMS,
|
||||
)
|
||||
from homeassistant.const import EVENT_COMPONENT_LOADED
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.setup import ATTR_COMPONENT
|
||||
from homeassistant.helpers import area_registry, entity_registry, intent
|
||||
|
||||
from .agent import AbstractConversationAgent, ConversationResult
|
||||
from .const import DOMAIN
|
||||
from .util import create_matcher
|
||||
|
||||
REGEX_TURN_COMMAND = re.compile(r"turn (?P<name>(?: |\w)+) (?P<command>\w+)")
|
||||
REGEX_TYPE = type(re.compile(""))
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UTTERANCES = {
|
||||
"cover": {
|
||||
INTENT_OPEN_COVER: ["Open [the] [a] [an] {name}[s]"],
|
||||
INTENT_CLOSE_COVER: ["Close [the] [a] [an] {name}[s]"],
|
||||
},
|
||||
"shopping_list": {
|
||||
INTENT_ADD_ITEM: ["Add [the] [a] [an] {item} to my shopping list"],
|
||||
INTENT_LAST_ITEMS: ["What is on my shopping list"],
|
||||
},
|
||||
}
|
||||
REGEX_TYPE = type(re.compile(""))
|
||||
|
||||
|
||||
@core.callback
|
||||
@ -50,12 +40,22 @@ def async_register(hass, intent_type, utterances):
|
||||
conf.append(create_matcher(utterance))
|
||||
|
||||
|
||||
@dataclass
|
||||
class LanguageIntents:
|
||||
"""Loaded intents for a language."""
|
||||
|
||||
intents: Intents
|
||||
intents_dict: dict[str, Any]
|
||||
loaded_components: set[str]
|
||||
|
||||
|
||||
class DefaultAgent(AbstractConversationAgent):
|
||||
"""Default agent for conversation agent."""
|
||||
|
||||
def __init__(self, hass: core.HomeAssistant) -> None:
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents] = {}
|
||||
|
||||
async def async_initialize(self, config):
|
||||
"""Initialize the default agent."""
|
||||
@ -63,52 +63,12 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
await setup.async_setup_component(self.hass, "intent", {})
|
||||
|
||||
config = config.get(DOMAIN, {})
|
||||
intents = self.hass.data.setdefault(DOMAIN, {})
|
||||
self.hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
for intent_type, utterances in config.get("intents", {}).items():
|
||||
if (conf := intents.get(intent_type)) is None:
|
||||
conf = intents[intent_type] = []
|
||||
|
||||
conf.extend(create_matcher(utterance) for utterance in utterances)
|
||||
|
||||
# We strip trailing 's' from name because our state matcher will fail
|
||||
# if a letter is not there. By removing 's' we can match singular and
|
||||
# plural names.
|
||||
|
||||
async_register(
|
||||
self.hass,
|
||||
intent.INTENT_TURN_ON,
|
||||
["Turn [the] [a] {name}[s] on", "Turn on [the] [a] [an] {name}[s]"],
|
||||
)
|
||||
async_register(
|
||||
self.hass,
|
||||
intent.INTENT_TURN_OFF,
|
||||
["Turn [the] [a] [an] {name}[s] off", "Turn off [the] [a] [an] {name}[s]"],
|
||||
)
|
||||
async_register(
|
||||
self.hass,
|
||||
intent.INTENT_TOGGLE,
|
||||
["Toggle [the] [a] [an] {name}[s]", "[the] [a] [an] {name}[s] toggle"],
|
||||
)
|
||||
|
||||
@callback
|
||||
def component_loaded(event):
|
||||
"""Handle a new component loaded."""
|
||||
self.register_utterances(event.data[ATTR_COMPONENT])
|
||||
|
||||
self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded)
|
||||
|
||||
# Check already loaded components.
|
||||
for component in self.hass.config.components:
|
||||
self.register_utterances(component)
|
||||
|
||||
@callback
|
||||
def register_utterances(self, component):
|
||||
"""Register utterances for a component."""
|
||||
if component not in UTTERANCES:
|
||||
return
|
||||
for intent_type, sentences in UTTERANCES[component].items():
|
||||
async_register(self.hass, intent_type, sentences)
|
||||
if config:
|
||||
_LOGGER.warning(
|
||||
"Custom intent sentences have been moved to config/custom_sentences"
|
||||
)
|
||||
|
||||
async def async_process(
|
||||
self,
|
||||
@ -118,25 +78,124 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
language: str | None = None,
|
||||
) -> ConversationResult | None:
|
||||
"""Process a sentence."""
|
||||
intents = self.hass.data[DOMAIN]
|
||||
language = language or self.hass.config.language
|
||||
lang_intents = self._lang_intents.get(language)
|
||||
|
||||
for intent_type, matchers in intents.items():
|
||||
for matcher in matchers:
|
||||
if not (match := matcher.match(text)):
|
||||
# Reload intents if missing or new components
|
||||
if lang_intents is None or (
|
||||
lang_intents.loaded_components - self.hass.config.components
|
||||
):
|
||||
# Load intents in executor
|
||||
lang_intents = await self.hass.async_add_executor_job(
|
||||
self.get_or_load_intents,
|
||||
language,
|
||||
)
|
||||
|
||||
if lang_intents is None:
|
||||
# No intents loaded
|
||||
_LOGGER.warning("No intents were loaded for language: %s", language)
|
||||
return None
|
||||
|
||||
slot_lists: dict[str, SlotList] = {
|
||||
"area": self._make_areas_list(),
|
||||
"name": self._make_names_list(),
|
||||
}
|
||||
|
||||
result = recognize(text, lang_intents.intents, slot_lists=slot_lists)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
intent_response = await intent.async_handle(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
result.intent.name,
|
||||
{entity.name: {"value": entity.value} for entity in result.entities_list},
|
||||
text,
|
||||
context,
|
||||
language,
|
||||
)
|
||||
|
||||
return ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
def get_or_load_intents(self, language: str) -> LanguageIntents | None:
|
||||
"""Load all intents for language."""
|
||||
lang_intents = self._lang_intents.get(language)
|
||||
|
||||
if lang_intents is None:
|
||||
intents_dict: dict[str, Any] = {}
|
||||
loaded_components: set[str] = set()
|
||||
else:
|
||||
intents_dict = lang_intents.intents_dict
|
||||
loaded_components = lang_intents.loaded_components
|
||||
|
||||
# Check if any new components have been loaded
|
||||
intents_changed = False
|
||||
for component in self.hass.config.components:
|
||||
if component in loaded_components:
|
||||
continue
|
||||
|
||||
# Don't check component again
|
||||
loaded_components.add(component)
|
||||
|
||||
# Check for intents for this component with the target language
|
||||
component_intents = get_intents(component, language)
|
||||
if component_intents:
|
||||
# Merge sentences into existing dictionary
|
||||
merge_dict(intents_dict, component_intents)
|
||||
|
||||
# Will need to recreate graph
|
||||
intents_changed = True
|
||||
|
||||
if not intents_dict:
|
||||
return None
|
||||
|
||||
if not intents_changed and lang_intents is not None:
|
||||
return lang_intents
|
||||
|
||||
# This can be made faster by not re-parsing existing sentences.
|
||||
# But it will likely only be called once anyways, unless new
|
||||
# components with sentences are often being loaded.
|
||||
intents = Intents.from_dict(intents_dict)
|
||||
|
||||
if lang_intents is None:
|
||||
lang_intents = LanguageIntents(intents, intents_dict, loaded_components)
|
||||
self._lang_intents[language] = lang_intents
|
||||
else:
|
||||
lang_intents.intents = intents
|
||||
|
||||
return lang_intents
|
||||
|
||||
def _make_areas_list(self) -> TextSlotList:
|
||||
"""Create slot list mapping area names/aliases to area ids."""
|
||||
registry = area_registry.async_get(self.hass)
|
||||
areas = []
|
||||
for entry in registry.async_list_areas():
|
||||
areas.append((entry.name, entry.id))
|
||||
if entry.aliases:
|
||||
for alias in entry.aliases:
|
||||
areas.append((alias, entry.id))
|
||||
|
||||
return TextSlotList.from_tuples(areas)
|
||||
|
||||
def _make_names_list(self) -> TextSlotList:
|
||||
"""Create slot list mapping entity names/aliases to entity ids."""
|
||||
states = self.hass.states.async_all()
|
||||
registry = entity_registry.async_get(self.hass)
|
||||
names = []
|
||||
for state in states:
|
||||
entry = registry.async_get(state.entity_id)
|
||||
if entry is not None:
|
||||
if entry.entity_category:
|
||||
# Skip configuration/diagnostic entities
|
||||
continue
|
||||
|
||||
intent_response = await intent.async_handle(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
intent_type,
|
||||
{key: {"value": value} for key, value in match.groupdict().items()},
|
||||
text,
|
||||
context,
|
||||
language,
|
||||
)
|
||||
if entry.aliases:
|
||||
for alias in entry.aliases:
|
||||
names.append((alias, state.entity_id))
|
||||
|
||||
return ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
# Default name
|
||||
names.append((state.name, state.entity_id))
|
||||
|
||||
return None
|
||||
return TextSlotList.from_tuples(names)
|
||||
|
@ -2,6 +2,7 @@
|
||||
"domain": "conversation",
|
||||
"name": "Conversation",
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"requirements": ["hassil==0.2.3", "home-assistant-intents==0.0.1"],
|
||||
"dependencies": ["http"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"quality_scale": "internal",
|
||||
|
@ -1,12 +1,12 @@
|
||||
"""Module to coordinate user intentions."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import voluptuous as vol
|
||||
@ -16,7 +16,7 @@ from homeassistant.core import Context, HomeAssistant, State, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
from . import config_validation as cv
|
||||
from . import area_registry, config_validation as cv, entity_registry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_SlotsType = dict[str, Any]
|
||||
@ -119,7 +119,25 @@ def async_match_state(
|
||||
if states is None:
|
||||
states = hass.states.async_all()
|
||||
|
||||
state = _fuzzymatch(name, states, lambda state: state.name)
|
||||
name = name.casefold()
|
||||
state: State | None = None
|
||||
registry = entity_registry.async_get(hass)
|
||||
|
||||
for maybe_state in states:
|
||||
# Check entity id and name
|
||||
if name in (maybe_state.entity_id, maybe_state.name.casefold()):
|
||||
state = maybe_state
|
||||
else:
|
||||
# Check aliases
|
||||
entry = registry.async_get(maybe_state.entity_id)
|
||||
if (entry is not None) and entry.aliases:
|
||||
for alias in entry.aliases:
|
||||
if name == alias.casefold():
|
||||
state = maybe_state
|
||||
break
|
||||
|
||||
if state is not None:
|
||||
break
|
||||
|
||||
if state is None:
|
||||
raise IntentHandleError(f"Unable to find an entity called {name}")
|
||||
@ -127,6 +145,18 @@ def async_match_state(
|
||||
return state
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def async_match_area(
|
||||
hass: HomeAssistant, area_name: str
|
||||
) -> area_registry.AreaEntry | None:
|
||||
"""Find an area that matches the name."""
|
||||
registry = area_registry.async_get(hass)
|
||||
return registry.async_get_area(area_name) or registry.async_get_area_by_name(
|
||||
area_name
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_test_feature(state: State, feature: int, feature_name: str) -> None:
|
||||
"""Test if state supports a feature."""
|
||||
@ -173,29 +203,17 @@ class IntentHandler:
|
||||
return f"<{self.__class__.__name__} - {self.intent_type}>"
|
||||
|
||||
|
||||
def _fuzzymatch(name: str, items: Iterable[_T], key: Callable[[_T], str]) -> _T | None:
|
||||
"""Fuzzy matching function."""
|
||||
matches = []
|
||||
pattern = ".*?".join(name)
|
||||
regex = re.compile(pattern, re.IGNORECASE)
|
||||
for idx, item in enumerate(items):
|
||||
if match := regex.search(key(item)):
|
||||
# Add key length so we prefer shorter keys with the same group and start.
|
||||
# Add index so we pick first match in case same group, start, and key length.
|
||||
matches.append(
|
||||
(len(match.group()), match.start(), len(key(item)), idx, item)
|
||||
)
|
||||
|
||||
return sorted(matches)[0][4] if matches else None
|
||||
|
||||
|
||||
class ServiceIntentHandler(IntentHandler):
|
||||
"""Service Intent handler registration.
|
||||
|
||||
Service specific intent handler that calls a service by name/entity_id.
|
||||
"""
|
||||
|
||||
slot_schema = {vol.Required("name"): cv.string}
|
||||
slot_schema = {
|
||||
vol.Any("name", "area"): cv.string,
|
||||
vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, intent_type: str, domain: str, service: str, speech: str
|
||||
@ -210,26 +228,101 @@ class ServiceIntentHandler(IntentHandler):
|
||||
"""Handle the hass intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
state = async_match_state(hass, slots["name"]["value"])
|
||||
|
||||
await hass.services.async_call(
|
||||
self.domain,
|
||||
self.service,
|
||||
{ATTR_ENTITY_ID: state.entity_id},
|
||||
context=intent_obj.context,
|
||||
)
|
||||
if "area" in slots:
|
||||
# Entities in an area
|
||||
area_name = slots["area"]["value"]
|
||||
area = async_match_area(hass, area_name)
|
||||
assert area is not None
|
||||
assert area.id is not None
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.async_set_speech(self.speech.format(state.name))
|
||||
response.async_set_results(
|
||||
success_results=[
|
||||
# Optional domain filter
|
||||
domains: set[str] | None = None
|
||||
if "domain" in slots:
|
||||
domains = set(slots["domain"]["value"])
|
||||
|
||||
# Optional device class filter
|
||||
device_classes: set[str] | None = None
|
||||
if "device_class" in slots:
|
||||
device_classes = set(slots["device_class"]["value"])
|
||||
|
||||
success_results = [
|
||||
IntentResponseTarget(
|
||||
type=IntentResponseTargetType.ENTITY,
|
||||
name=state.name,
|
||||
id=state.entity_id,
|
||||
),
|
||||
],
|
||||
)
|
||||
type=IntentResponseTargetType.AREA, name=area.name, id=area.id
|
||||
)
|
||||
]
|
||||
service_coros = []
|
||||
registry = entity_registry.async_get(hass)
|
||||
for entity_entry in entity_registry.async_entries_for_area(
|
||||
registry, area.id
|
||||
):
|
||||
if entity_entry.entity_category:
|
||||
# Skip diagnostic entities
|
||||
continue
|
||||
|
||||
if domains and (entity_entry.domain not in domains):
|
||||
# Skip entity not in the domain
|
||||
continue
|
||||
|
||||
if device_classes and (entity_entry.device_class not in device_classes):
|
||||
# Skip entity with wrong device class
|
||||
continue
|
||||
|
||||
service_coros.append(
|
||||
hass.services.async_call(
|
||||
self.domain,
|
||||
self.service,
|
||||
{ATTR_ENTITY_ID: entity_entry.entity_id},
|
||||
context=intent_obj.context,
|
||||
)
|
||||
)
|
||||
|
||||
state = hass.states.get(entity_entry.entity_id)
|
||||
assert state is not None
|
||||
|
||||
success_results.append(
|
||||
IntentResponseTarget(
|
||||
type=IntentResponseTargetType.ENTITY,
|
||||
name=state.name,
|
||||
id=entity_entry.entity_id,
|
||||
),
|
||||
)
|
||||
|
||||
if not service_coros:
|
||||
raise IntentHandleError("No entities matched")
|
||||
|
||||
# Handle service calls in parallel.
|
||||
# We will need to handle partial failures here.
|
||||
await asyncio.gather(*service_coros)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.async_set_speech(self.speech.format(area.name))
|
||||
response.async_set_results(
|
||||
success_results=success_results,
|
||||
)
|
||||
else:
|
||||
# Single entity
|
||||
state = async_match_state(hass, slots["name"]["value"])
|
||||
|
||||
await hass.services.async_call(
|
||||
self.domain,
|
||||
self.service,
|
||||
{ATTR_ENTITY_ID: state.entity_id},
|
||||
context=intent_obj.context,
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.async_set_speech(self.speech.format(state.name))
|
||||
response.async_set_results(
|
||||
success_results=[
|
||||
IntentResponseTarget(
|
||||
type=IntentResponseTargetType.ENTITY,
|
||||
name=state.name,
|
||||
id=state.entity_id,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
@ -867,6 +867,9 @@ hass-nabucasa==0.61.0
|
||||
# homeassistant.components.splunk
|
||||
hass_splunk==0.1.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
hassil==0.2.3
|
||||
|
||||
# homeassistant.components.tasmota
|
||||
hatasmota==0.6.2
|
||||
|
||||
@ -900,6 +903,9 @@ holidays==0.18.0
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230104.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==0.0.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.7.2
|
||||
|
||||
|
@ -656,6 +656,9 @@ habitipy==0.2.0
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==0.61.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
hassil==0.2.3
|
||||
|
||||
# homeassistant.components.tasmota
|
||||
hatasmota==0.6.2
|
||||
|
||||
@ -680,6 +683,9 @@ holidays==0.18.0
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230104.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==0.0.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.7.2
|
||||
|
||||
|
@ -5,11 +5,11 @@ from unittest.mock import ANY, patch
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.core import DOMAIN as HASS_DOMAIN, Context
|
||||
from homeassistant.core import DOMAIN as HASS_DOMAIN
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import async_mock_intent, async_mock_service
|
||||
from tests.common import async_mock_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -20,191 +20,14 @@ async def init_components(hass):
|
||||
assert await async_setup_component(hass, "intent", {})
|
||||
|
||||
|
||||
async def test_calling_intent(hass):
|
||||
"""Test calling an intent from a conversation."""
|
||||
intents = async_mock_intent(hass, "OrderBeer")
|
||||
|
||||
result = await async_setup_component(hass, "homeassistant", {})
|
||||
assert result
|
||||
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"conversation",
|
||||
{"conversation": {"intents": {"OrderBeer": ["I would like the {type} beer"]}}},
|
||||
)
|
||||
assert result
|
||||
|
||||
context = Context()
|
||||
|
||||
await hass.services.async_call(
|
||||
"conversation",
|
||||
"process",
|
||||
{conversation.ATTR_TEXT: "I would like the Grolsch beer"},
|
||||
context=context,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(intents) == 1
|
||||
intent = intents[0]
|
||||
assert intent.platform == "conversation"
|
||||
assert intent.intent_type == "OrderBeer"
|
||||
assert intent.slots == {"type": {"value": "Grolsch"}}
|
||||
assert intent.text_input == "I would like the Grolsch beer"
|
||||
assert intent.context is context
|
||||
|
||||
|
||||
async def test_register_before_setup(hass):
|
||||
"""Test calling an intent from a conversation."""
|
||||
intents = async_mock_intent(hass, "OrderBeer")
|
||||
|
||||
hass.components.conversation.async_register("OrderBeer", ["A {type} beer, please"])
|
||||
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"conversation",
|
||||
{"conversation": {"intents": {"OrderBeer": ["I would like the {type} beer"]}}},
|
||||
)
|
||||
assert result
|
||||
|
||||
await hass.services.async_call(
|
||||
"conversation", "process", {conversation.ATTR_TEXT: "A Grolsch beer, please"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(intents) == 1
|
||||
intent = intents[0]
|
||||
assert intent.platform == "conversation"
|
||||
assert intent.intent_type == "OrderBeer"
|
||||
assert intent.slots == {"type": {"value": "Grolsch"}}
|
||||
assert intent.text_input == "A Grolsch beer, please"
|
||||
|
||||
await hass.services.async_call(
|
||||
"conversation",
|
||||
"process",
|
||||
{conversation.ATTR_TEXT: "I would like the Grolsch beer"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(intents) == 2
|
||||
intent = intents[1]
|
||||
assert intent.platform == "conversation"
|
||||
assert intent.intent_type == "OrderBeer"
|
||||
assert intent.slots == {"type": {"value": "Grolsch"}}
|
||||
assert intent.text_input == "I would like the Grolsch beer"
|
||||
|
||||
|
||||
async def test_http_processing_intent(hass, hass_client, hass_admin_user):
|
||||
async def test_http_processing_intent(
|
||||
hass, init_components, hass_client, hass_admin_user
|
||||
):
|
||||
"""Test processing intent via HTTP API."""
|
||||
|
||||
class TestIntentHandler(intent.IntentHandler):
|
||||
"""Test Intent Handler."""
|
||||
|
||||
intent_type = "OrderBeer"
|
||||
|
||||
async def async_handle(self, intent):
|
||||
"""Handle the intent."""
|
||||
assert intent.context.user_id == hass_admin_user.id
|
||||
response = intent.create_response()
|
||||
response.async_set_speech(
|
||||
"I've ordered a {}!".format(intent.slots["type"]["value"])
|
||||
)
|
||||
response.async_set_card(
|
||||
"Beer ordered", "You chose a {}.".format(intent.slots["type"]["value"])
|
||||
)
|
||||
return response
|
||||
|
||||
intent.async_register(hass, TestIntentHandler())
|
||||
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"conversation",
|
||||
{"conversation": {"intents": {"OrderBeer": ["I would like the {type} beer"]}}},
|
||||
)
|
||||
assert result
|
||||
|
||||
hass.states.async_set("light.kitchen", "on")
|
||||
client = await hass_client()
|
||||
resp = await client.post(
|
||||
"/api/conversation/process", json={"text": "I would like the Grolsch beer"}
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
assert data == {
|
||||
"response": {
|
||||
"response_type": "action_done",
|
||||
"card": {
|
||||
"simple": {"content": "You chose a Grolsch.", "title": "Beer ordered"}
|
||||
},
|
||||
"speech": {
|
||||
"plain": {
|
||||
"extra_data": None,
|
||||
"speech": "I've ordered a Grolsch!",
|
||||
}
|
||||
},
|
||||
"language": hass.config.language,
|
||||
"data": {"targets": [], "success": [], "failed": []},
|
||||
},
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_http_failed_action(hass, hass_client, hass_admin_user):
|
||||
"""Test processing intent via HTTP API with a partial completion."""
|
||||
|
||||
class TestIntentHandler(intent.IntentHandler):
|
||||
"""Test Intent Handler."""
|
||||
|
||||
intent_type = "TurnOffLights"
|
||||
|
||||
async def async_handle(self, handle_intent: intent.Intent):
|
||||
"""Handle the intent."""
|
||||
response = handle_intent.create_response()
|
||||
area = handle_intent.slots["area"]["value"]
|
||||
|
||||
# Mark some targets as successful, others as failed
|
||||
response.async_set_targets(
|
||||
intent_targets=[
|
||||
intent.IntentResponseTarget(
|
||||
type=intent.IntentResponseTargetType.AREA, name=area, id=area
|
||||
)
|
||||
]
|
||||
)
|
||||
response.async_set_results(
|
||||
success_results=[
|
||||
intent.IntentResponseTarget(
|
||||
type=intent.IntentResponseTargetType.ENTITY,
|
||||
name="light1",
|
||||
id="light.light1",
|
||||
)
|
||||
],
|
||||
failed_results=[
|
||||
intent.IntentResponseTarget(
|
||||
type=intent.IntentResponseTargetType.ENTITY,
|
||||
name="light2",
|
||||
id="light.light2",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
intent.async_register(hass, TestIntentHandler())
|
||||
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"conversation",
|
||||
{
|
||||
"conversation": {
|
||||
"intents": {"TurnOffLights": ["turn off the lights in the {area}"]}
|
||||
}
|
||||
},
|
||||
)
|
||||
assert result
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.post(
|
||||
"/api/conversation/process", json={"text": "Turn off the lights in the kitchen"}
|
||||
"/api/conversation/process", json={"text": "turn on kitchen"}
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
@ -214,12 +37,19 @@ async def test_http_failed_action(hass, hass_client, hass_admin_user):
|
||||
"response": {
|
||||
"response_type": "action_done",
|
||||
"card": {},
|
||||
"speech": {},
|
||||
"speech": {
|
||||
"plain": {
|
||||
"extra_data": None,
|
||||
"speech": "Turned kitchen on",
|
||||
}
|
||||
},
|
||||
"language": hass.config.language,
|
||||
"data": {
|
||||
"targets": [{"type": "area", "id": "kitchen", "name": "kitchen"}],
|
||||
"success": [{"type": "entity", "id": "light.light1", "name": "light1"}],
|
||||
"failed": [{"type": "entity", "id": "light.light2", "name": "light2"}],
|
||||
"targets": [],
|
||||
"success": [
|
||||
{"id": "light.kitchen", "name": "kitchen", "type": "entity"}
|
||||
],
|
||||
"failed": [],
|
||||
},
|
||||
},
|
||||
"conversation_id": None,
|
||||
@ -262,24 +92,6 @@ async def test_turn_off_intent(hass, init_components, sentence):
|
||||
assert call.data == {"entity_id": "light.kitchen"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sentence", ("toggle kitchen", "kitchen toggle"))
|
||||
async def test_toggle_intent(hass, init_components, sentence):
|
||||
"""Test calling the turn on intent."""
|
||||
hass.states.async_set("light.kitchen", "on")
|
||||
calls = async_mock_service(hass, HASS_DOMAIN, "toggle")
|
||||
|
||||
await hass.services.async_call(
|
||||
"conversation", "process", {conversation.ATTR_TEXT: sentence}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call.domain == HASS_DOMAIN
|
||||
assert call.service == "toggle"
|
||||
assert call.data == {"entity_id": "light.kitchen"}
|
||||
|
||||
|
||||
async def test_http_api(hass, init_components, hass_client):
|
||||
"""Test the HTTP conversation API."""
|
||||
client = await hass_client()
|
||||
@ -324,55 +136,24 @@ async def test_http_api_no_match(hass, init_components, hass_client):
|
||||
"""Test the HTTP conversation API with an intent match failure."""
|
||||
client = await hass_client()
|
||||
|
||||
# Sentence should not match any intents
|
||||
# Shouldn't match any intents
|
||||
resp = await client.post("/api/conversation/process", json={"text": "do something"})
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
assert data == {
|
||||
"response": {
|
||||
"response_type": "error",
|
||||
"card": {},
|
||||
"speech": {
|
||||
"plain": {
|
||||
"extra_data": None,
|
||||
"speech": "Sorry, I didn't understand that",
|
||||
},
|
||||
},
|
||||
"language": hass.config.language,
|
||||
"response_type": "error",
|
||||
"data": {
|
||||
"code": "no_intent_match",
|
||||
},
|
||||
},
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_http_api_no_valid_targets(hass, init_components, hass_client):
|
||||
"""Test the HTTP conversation API with no valid targets."""
|
||||
client = await hass_client()
|
||||
|
||||
# No kitchen light
|
||||
resp = await client.post(
|
||||
"/api/conversation/process", json={"text": "turn on the kitchen"}
|
||||
)
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
assert data == {
|
||||
"response": {
|
||||
"response_type": "error",
|
||||
"card": {},
|
||||
"speech": {
|
||||
"plain": {
|
||||
"extra_data": None,
|
||||
"speech": "Unable to find an entity called kitchen",
|
||||
},
|
||||
},
|
||||
"language": hass.config.language,
|
||||
"data": {
|
||||
"code": "no_valid_targets",
|
||||
},
|
||||
"data": {"code": "no_intent_match"},
|
||||
},
|
||||
"conversation_id": None,
|
||||
}
|
||||
|
@ -168,7 +168,7 @@ async def test_turn_on_multiple_intent(hass):
|
||||
calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
|
||||
|
||||
response = await intent.async_handle(
|
||||
hass, "test", "HassTurnOn", {"name": {"value": "test lights"}}
|
||||
hass, "test", "HassTurnOn", {"name": {"value": "test lights 2"}}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
@ -20,7 +20,7 @@ async def test_intent_set_color(hass):
|
||||
hass,
|
||||
"test",
|
||||
intent.INTENT_SET,
|
||||
{"name": {"value": "Hello"}, "color": {"value": "blue"}},
|
||||
{"name": {"value": "Hello 2"}, "color": {"value": "blue"}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@ -68,7 +68,7 @@ async def test_intent_set_color_and_brightness(hass):
|
||||
"test",
|
||||
intent.INTENT_SET,
|
||||
{
|
||||
"name": {"value": "Hello"},
|
||||
"name": {"value": "Hello 2"},
|
||||
"color": {"value": "blue"},
|
||||
"brightness": {"value": "20"},
|
||||
},
|
||||
|
@ -3,8 +3,9 @@
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME
|
||||
from homeassistant.core import State
|
||||
from homeassistant.helpers import config_validation as cv, intent
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry, intent
|
||||
|
||||
|
||||
class MockIntentHandler(intent.IntentHandler):
|
||||
@ -15,14 +16,26 @@ class MockIntentHandler(intent.IntentHandler):
|
||||
self.slot_schema = slot_schema
|
||||
|
||||
|
||||
def test_async_match_state():
|
||||
async def test_async_match_state(hass):
|
||||
"""Test async_match_state helper."""
|
||||
state1 = State("light.kitchen", "on")
|
||||
state2 = State("switch.kitchen", "on")
|
||||
state1 = State(
|
||||
"light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"}
|
||||
)
|
||||
state2 = State(
|
||||
"switch.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen switch"}
|
||||
)
|
||||
registry = entity_registry.async_get(hass)
|
||||
registry.async_get_or_create(
|
||||
"switch", "demo", "1234", suggested_object_id="kitchen"
|
||||
)
|
||||
registry.async_update_entity(state2.entity_id, aliases={"kill switch"})
|
||||
|
||||
state = intent.async_match_state(None, "kitch", [state1, state2])
|
||||
state = intent.async_match_state(hass, "kitchen light", [state1, state2])
|
||||
assert state is state1
|
||||
|
||||
state = intent.async_match_state(hass, "kill switch", [state1, state2])
|
||||
assert state is state2
|
||||
|
||||
|
||||
def test_async_validate_slots():
|
||||
"""Test async_validate_slots of IntentHandler."""
|
||||
@ -38,21 +51,3 @@ def test_async_validate_slots():
|
||||
handler1.async_validate_slots(
|
||||
{"name": {"value": "kitchen"}, "probability": {"value": "0.5"}}
|
||||
)
|
||||
|
||||
|
||||
def test_fuzzy_match():
|
||||
"""Test _fuzzymatch."""
|
||||
state1 = State("light.living_room_northwest", "off")
|
||||
state2 = State("light.living_room_north", "off")
|
||||
state3 = State("light.living_room_northeast", "off")
|
||||
state4 = State("light.living_room_west", "off")
|
||||
state5 = State("light.living_room", "off")
|
||||
states = [state1, state2, state3, state4, state5]
|
||||
|
||||
state = intent._fuzzymatch("Living Room", states, lambda state: state.name)
|
||||
assert state == state5
|
||||
|
||||
state = intent._fuzzymatch(
|
||||
"Living Room Northwest", states, lambda state: state.name
|
||||
)
|
||||
assert state == state1
|
||||
|
Loading…
x
Reference in New Issue
Block a user