2022-01-17 15:27:25 +00:00
|
|
|
"""Standard conversation implementation for Home Assistant."""
|
2021-03-17 22:43:55 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2023-01-19 01:36:51 +00:00
|
|
|
import asyncio
|
|
|
|
from collections import defaultdict
|
2023-01-21 02:39:49 +00:00
|
|
|
from collections.abc import Iterable
|
2023-01-07 21:20:21 +00:00
|
|
|
from dataclasses import dataclass
|
|
|
|
import logging
|
2023-01-09 22:48:59 +00:00
|
|
|
from pathlib import Path
|
2019-10-18 18:46:45 +00:00
|
|
|
import re
|
2023-01-24 03:38:41 +00:00
|
|
|
from typing import IO, Any
|
2023-01-07 21:20:21 +00:00
|
|
|
|
2023-01-24 03:38:41 +00:00
|
|
|
from hassil.intents import Intents, ResponseType, SlotList, TextSlotList
|
2023-01-07 21:20:21 +00:00
|
|
|
from hassil.recognize import recognize
|
|
|
|
from hassil.util import merge_dict
|
|
|
|
from home_assistant_intents import get_intents
|
2023-01-09 22:48:59 +00:00
|
|
|
import yaml
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2019-12-01 22:12:57 +00:00
|
|
|
from homeassistant import core, setup
|
2023-01-24 03:38:41 +00:00
|
|
|
from homeassistant.helpers import area_registry, entity_registry, intent, template
|
|
|
|
from homeassistant.helpers.json import json_loads
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2023-01-25 03:47:49 +00:00
|
|
|
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
|
2019-10-18 18:46:45 +00:00
|
|
|
from .const import DOMAIN
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2023-01-24 03:38:41 +00:00
|
|
|
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
REGEX_TYPE = type(re.compile(""))
|
2019-10-18 18:46:45 +00:00
|
|
|
|
|
|
|
|
2023-01-24 03:38:41 +00:00
|
|
|
def json_load(fp: IO[str]) -> dict[str, Any]:
|
|
|
|
"""Wrap json_loads for get_intents."""
|
|
|
|
return json_loads(fp.read())
|
|
|
|
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
@dataclass
|
|
|
|
class LanguageIntents:
|
|
|
|
"""Loaded intents for a language."""
|
|
|
|
|
|
|
|
intents: Intents
|
|
|
|
intents_dict: dict[str, Any]
|
2023-01-24 03:38:41 +00:00
|
|
|
intent_responses: dict[str, Any]
|
|
|
|
error_responses: dict[str, Any]
|
2023-01-07 21:20:21 +00:00
|
|
|
loaded_components: set[str]
|
|
|
|
|
|
|
|
|
2023-01-21 02:39:49 +00:00
|
|
|
def _get_language_variations(language: str) -> Iterable[str]:
|
|
|
|
"""Generate language codes with and without region."""
|
|
|
|
yield language
|
|
|
|
|
|
|
|
parts = re.split(r"([-_])", language)
|
|
|
|
if len(parts) == 3:
|
|
|
|
lang, sep, region = parts
|
|
|
|
if sep == "_":
|
|
|
|
# en_US -> en-US
|
|
|
|
yield f"{lang}-{region}"
|
|
|
|
|
|
|
|
# en-US -> en
|
|
|
|
yield lang
|
|
|
|
|
|
|
|
|
2019-10-18 18:46:45 +00:00
|
|
|
class DefaultAgent(AbstractConversationAgent):
|
|
|
|
"""Default agent for conversation agent."""
|
|
|
|
|
2021-05-20 15:51:39 +00:00
|
|
|
def __init__(self, hass: core.HomeAssistant) -> None:
|
2019-10-18 18:46:45 +00:00
|
|
|
"""Initialize the default agent."""
|
|
|
|
self.hass = hass
|
2023-01-07 21:20:21 +00:00
|
|
|
self._lang_intents: dict[str, LanguageIntents] = {}
|
2023-01-19 01:36:51 +00:00
|
|
|
self._lang_lock: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2023-01-21 02:39:49 +00:00
|
|
|
# intent -> [sentences]
|
|
|
|
self._config_intents: dict[str, Any] = {}
|
2023-01-29 12:16:29 +00:00
|
|
|
self._areas_list: TextSlotList | None = None
|
|
|
|
self._names_list: TextSlotList | None = None
|
2023-01-21 02:39:49 +00:00
|
|
|
|
|
|
|
async def async_initialize(self, config_intents):
|
2019-10-18 18:46:45 +00:00
|
|
|
"""Initialize the default agent."""
|
2019-12-01 22:12:57 +00:00
|
|
|
if "intent" not in self.hass.config.components:
|
|
|
|
await setup.async_setup_component(self.hass, "intent", {})
|
|
|
|
|
2023-01-21 02:39:49 +00:00
|
|
|
# Intents from config may only contains sentences for HA config's language
|
|
|
|
if config_intents:
|
|
|
|
self._config_intents = config_intents
|
2023-01-19 18:59:02 +00:00
|
|
|
|
2023-01-29 12:16:29 +00:00
|
|
|
self.hass.bus.async_listen(
|
|
|
|
area_registry.EVENT_AREA_REGISTRY_UPDATED,
|
|
|
|
self._async_handle_area_registry_changed,
|
|
|
|
run_immediately=True,
|
|
|
|
)
|
|
|
|
self.hass.bus.async_listen(
|
|
|
|
entity_registry.EVENT_ENTITY_REGISTRY_UPDATED,
|
|
|
|
self._async_handle_entity_registry_changed,
|
|
|
|
run_immediately=True,
|
|
|
|
)
|
|
|
|
self.hass.bus.async_listen(
|
|
|
|
core.EVENT_STATE_CHANGED,
|
|
|
|
self._async_handle_state_changed,
|
|
|
|
run_immediately=True,
|
|
|
|
)
|
|
|
|
|
2023-01-25 03:47:49 +00:00
|
|
|
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
|
2023-01-07 21:20:21 +00:00
|
|
|
"""Process a sentence."""
|
2023-01-25 03:47:49 +00:00
|
|
|
language = user_input.language or self.hass.config.language
|
2023-01-07 21:20:21 +00:00
|
|
|
lang_intents = self._lang_intents.get(language)
|
2023-01-25 03:47:49 +00:00
|
|
|
conversation_id = None # Not supported
|
2023-01-07 21:20:21 +00:00
|
|
|
|
|
|
|
# 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
|
2023-01-19 01:36:51 +00:00
|
|
|
lang_intents = await self.async_get_or_load_intents(language)
|
2023-01-07 21:20:21 +00:00
|
|
|
|
|
|
|
if lang_intents is None:
|
|
|
|
# No intents loaded
|
|
|
|
_LOGGER.warning("No intents were loaded for language: %s", language)
|
2023-01-24 03:38:41 +00:00
|
|
|
return _make_error_result(
|
|
|
|
language,
|
|
|
|
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
|
|
|
|
_DEFAULT_ERROR_TEXT,
|
|
|
|
conversation_id,
|
|
|
|
)
|
2023-01-07 21:20:21 +00:00
|
|
|
|
|
|
|
slot_lists: dict[str, SlotList] = {
|
|
|
|
"area": self._make_areas_list(),
|
|
|
|
"name": self._make_names_list(),
|
|
|
|
}
|
|
|
|
|
2023-01-25 03:47:49 +00:00
|
|
|
result = recognize(user_input.text, lang_intents.intents, slot_lists=slot_lists)
|
2023-01-07 21:20:21 +00:00
|
|
|
if result is None:
|
2023-01-25 03:47:49 +00:00
|
|
|
_LOGGER.debug("No intent was matched for '%s'", user_input.text)
|
2023-01-24 03:38:41 +00:00
|
|
|
return _make_error_result(
|
|
|
|
language,
|
|
|
|
intent.IntentResponseErrorCode.NO_INTENT_MATCH,
|
|
|
|
self._get_error_text(ResponseType.NO_INTENT, lang_intents),
|
|
|
|
conversation_id,
|
|
|
|
)
|
2023-01-07 21:20:21 +00:00
|
|
|
|
2023-01-24 03:38:41 +00:00
|
|
|
try:
|
|
|
|
intent_response = await intent.async_handle(
|
|
|
|
self.hass,
|
|
|
|
DOMAIN,
|
|
|
|
result.intent.name,
|
|
|
|
{
|
|
|
|
entity.name: {"value": entity.value}
|
|
|
|
for entity in result.entities_list
|
|
|
|
},
|
2023-01-25 03:47:49 +00:00
|
|
|
user_input.text,
|
|
|
|
user_input.context,
|
2023-01-24 03:38:41 +00:00
|
|
|
language,
|
|
|
|
)
|
|
|
|
except intent.IntentHandleError:
|
|
|
|
_LOGGER.exception("Intent handling error")
|
|
|
|
return _make_error_result(
|
|
|
|
language,
|
|
|
|
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
|
|
|
|
self._get_error_text(ResponseType.HANDLE_ERROR, lang_intents),
|
|
|
|
conversation_id,
|
|
|
|
)
|
|
|
|
except intent.IntentUnexpectedError:
|
|
|
|
_LOGGER.exception("Unexpected intent error")
|
|
|
|
return _make_error_result(
|
|
|
|
language,
|
|
|
|
intent.IntentResponseErrorCode.UNKNOWN,
|
|
|
|
self._get_error_text(ResponseType.HANDLE_ERROR, lang_intents),
|
|
|
|
conversation_id,
|
|
|
|
)
|
|
|
|
|
|
|
|
if (
|
|
|
|
(not intent_response.speech)
|
|
|
|
and (intent_response.intent is not None)
|
|
|
|
and (response_key := result.response)
|
|
|
|
):
|
|
|
|
# Use response template, if available
|
|
|
|
response_str = lang_intents.intent_responses.get(
|
|
|
|
result.intent.name, {}
|
|
|
|
).get(response_key)
|
|
|
|
if response_str:
|
|
|
|
response_template = template.Template(response_str, self.hass)
|
2023-01-26 15:48:49 +00:00
|
|
|
speech = response_template.async_render(
|
|
|
|
{
|
|
|
|
"slots": {
|
|
|
|
entity_name: entity_value.text or entity_value.value
|
|
|
|
for entity_name, entity_value in result.entities.items()
|
2023-01-24 03:38:41 +00:00
|
|
|
}
|
2023-01-26 15:48:49 +00:00
|
|
|
}
|
2023-01-24 03:38:41 +00:00
|
|
|
)
|
2023-01-07 21:20:21 +00:00
|
|
|
|
2023-01-26 15:48:49 +00:00
|
|
|
# Normalize whitespace
|
|
|
|
speech = " ".join(speech.strip().split())
|
|
|
|
intent_response.async_set_speech(speech)
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
return ConversationResult(
|
|
|
|
response=intent_response, conversation_id=conversation_id
|
2019-10-18 18:46:45 +00:00
|
|
|
)
|
|
|
|
|
2023-01-19 01:36:51 +00:00
|
|
|
async def async_reload(self, language: str | None = None):
|
|
|
|
"""Clear cached intents for a language."""
|
|
|
|
if language is None:
|
|
|
|
language = self.hass.config.language
|
|
|
|
|
|
|
|
self._lang_intents.pop(language, None)
|
|
|
|
_LOGGER.debug("Cleared intents for language: %s", language)
|
|
|
|
|
|
|
|
async def async_prepare(self, language: str | None = None):
|
|
|
|
"""Load intents for a language."""
|
|
|
|
if language is None:
|
|
|
|
language = self.hass.config.language
|
|
|
|
|
|
|
|
lang_intents = await self.async_get_or_load_intents(language)
|
|
|
|
|
|
|
|
if lang_intents is None:
|
|
|
|
# No intents loaded
|
|
|
|
_LOGGER.warning("No intents were loaded for language: %s", language)
|
|
|
|
|
|
|
|
async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None:
|
|
|
|
"""Load all intents of a language with lock."""
|
|
|
|
async with self._lang_lock[language]:
|
|
|
|
return await self.hass.async_add_executor_job(
|
|
|
|
self._get_or_load_intents,
|
|
|
|
language,
|
|
|
|
)
|
|
|
|
|
|
|
|
def _get_or_load_intents(self, language: str) -> LanguageIntents | None:
|
|
|
|
"""Load all intents for language (run inside executor)."""
|
2023-01-07 21:20:21 +00:00
|
|
|
lang_intents = self._lang_intents.get(language)
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
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
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
# Check if any new components have been loaded
|
|
|
|
intents_changed = False
|
2019-10-18 18:46:45 +00:00
|
|
|
for component in self.hass.config.components:
|
2023-01-07 21:20:21 +00:00
|
|
|
if component in loaded_components:
|
|
|
|
continue
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
# Don't check component again
|
|
|
|
loaded_components.add(component)
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2023-01-21 02:39:49 +00:00
|
|
|
# Check for intents for this component with the target language.
|
|
|
|
# Try en-US, en, etc.
|
|
|
|
for language_variation in _get_language_variations(language):
|
2023-01-24 03:38:41 +00:00
|
|
|
component_intents = get_intents(
|
|
|
|
component, language_variation, json_load=json_load
|
|
|
|
)
|
2023-01-21 02:39:49 +00:00
|
|
|
if component_intents:
|
|
|
|
# Merge sentences into existing dictionary
|
|
|
|
merge_dict(intents_dict, component_intents)
|
2023-01-07 21:20:21 +00:00
|
|
|
|
2023-01-21 02:39:49 +00:00
|
|
|
# Will need to recreate graph
|
|
|
|
intents_changed = True
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Loaded intents component=%s, language=%s", component, language
|
|
|
|
)
|
|
|
|
break
|
2023-01-09 22:48:59 +00:00
|
|
|
|
|
|
|
# Check for custom sentences in <config>/custom_sentences/<language>/
|
|
|
|
if lang_intents is None:
|
|
|
|
# Only load custom sentences once, otherwise they will be re-loaded
|
|
|
|
# when components change.
|
|
|
|
custom_sentences_dir = Path(
|
|
|
|
self.hass.config.path("custom_sentences", language)
|
|
|
|
)
|
|
|
|
if custom_sentences_dir.is_dir():
|
|
|
|
for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"):
|
|
|
|
with custom_sentences_path.open(
|
|
|
|
encoding="utf-8"
|
|
|
|
) as custom_sentences_file:
|
|
|
|
# Merge custom sentences
|
|
|
|
merge_dict(intents_dict, yaml.safe_load(custom_sentences_file))
|
|
|
|
|
|
|
|
# Will need to recreate graph
|
|
|
|
intents_changed = True
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Loaded custom sentences language=%s, path=%s",
|
|
|
|
language,
|
|
|
|
custom_sentences_path,
|
|
|
|
)
|
2023-01-07 21:20:21 +00:00
|
|
|
|
2023-01-21 02:39:49 +00:00
|
|
|
# Load sentences from HA config for default language only
|
|
|
|
if self._config_intents and (language == self.hass.config.language):
|
|
|
|
merge_dict(
|
|
|
|
intents_dict,
|
|
|
|
{
|
|
|
|
"intents": {
|
|
|
|
intent_name: {"data": [{"sentences": sentences}]}
|
|
|
|
for intent_name, sentences in self._config_intents.items()
|
|
|
|
}
|
|
|
|
},
|
|
|
|
)
|
|
|
|
intents_changed = True
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Loaded intents from configuration.yaml",
|
|
|
|
)
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
if not intents_dict:
|
|
|
|
return None
|
|
|
|
|
|
|
|
if not intents_changed and lang_intents is not None:
|
|
|
|
return lang_intents
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
# 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)
|
|
|
|
|
2023-01-24 03:38:41 +00:00
|
|
|
# Load responses
|
|
|
|
responses_dict = intents_dict.get("responses", {})
|
|
|
|
intent_responses = responses_dict.get("intents", {})
|
|
|
|
error_responses = responses_dict.get("errors", {})
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
if lang_intents is None:
|
2023-01-24 03:38:41 +00:00
|
|
|
lang_intents = LanguageIntents(
|
|
|
|
intents,
|
|
|
|
intents_dict,
|
|
|
|
intent_responses,
|
|
|
|
error_responses,
|
|
|
|
loaded_components,
|
|
|
|
)
|
2023-01-07 21:20:21 +00:00
|
|
|
self._lang_intents[language] = lang_intents
|
|
|
|
else:
|
|
|
|
lang_intents.intents = intents
|
2023-01-24 03:38:41 +00:00
|
|
|
lang_intents.intent_responses = intent_responses
|
|
|
|
lang_intents.error_responses = error_responses
|
2023-01-07 21:20:21 +00:00
|
|
|
|
|
|
|
return lang_intents
|
|
|
|
|
2023-01-29 12:16:29 +00:00
|
|
|
@core.callback
|
|
|
|
def _async_handle_area_registry_changed(self, event: core.Event) -> None:
|
|
|
|
"""Clear area area cache when the area registry has changed."""
|
|
|
|
self._areas_list = None
|
|
|
|
|
|
|
|
@core.callback
|
|
|
|
def _async_handle_entity_registry_changed(self, event: core.Event) -> None:
|
|
|
|
"""Clear names list cache when an entity changes aliases."""
|
|
|
|
if event.data["action"] == "update" and "aliases" not in event.data["changes"]:
|
|
|
|
return
|
|
|
|
self._names_list = None
|
|
|
|
|
|
|
|
@core.callback
|
|
|
|
def _async_handle_state_changed(self, event: core.Event) -> None:
|
|
|
|
"""Clear names list cache when a state is added or removed from the state machine."""
|
|
|
|
if event.data.get("old_state") and event.data.get("new_state"):
|
|
|
|
return
|
|
|
|
self._names_list = None
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
def _make_areas_list(self) -> TextSlotList:
|
|
|
|
"""Create slot list mapping area names/aliases to area ids."""
|
2023-01-29 12:16:29 +00:00
|
|
|
if self._areas_list is not None:
|
|
|
|
return self._areas_list
|
2023-01-07 21:20:21 +00:00
|
|
|
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))
|
|
|
|
|
2023-01-29 12:16:29 +00:00
|
|
|
self._areas_list = TextSlotList.from_tuples(areas)
|
|
|
|
return self._areas_list
|
2023-01-07 21:20:21 +00:00
|
|
|
|
|
|
|
def _make_names_list(self) -> TextSlotList:
|
|
|
|
"""Create slot list mapping entity names/aliases to entity ids."""
|
2023-01-29 12:16:29 +00:00
|
|
|
if self._names_list is not None:
|
|
|
|
return self._names_list
|
2023-01-07 21:20:21 +00:00
|
|
|
states = self.hass.states.async_all()
|
|
|
|
registry = entity_registry.async_get(self.hass)
|
|
|
|
names = []
|
|
|
|
for state in states:
|
2023-01-29 12:16:29 +00:00
|
|
|
context = {"domain": state.domain}
|
2023-01-24 03:38:41 +00:00
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
entry = registry.async_get(state.entity_id)
|
|
|
|
if entry is not None:
|
|
|
|
if entry.entity_category:
|
|
|
|
# Skip configuration/diagnostic entities
|
2019-10-18 18:46:45 +00:00
|
|
|
continue
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
if entry.aliases:
|
|
|
|
for alias in entry.aliases:
|
2023-01-24 03:38:41 +00:00
|
|
|
names.append((alias, state.entity_id, context))
|
2023-01-07 21:20:21 +00:00
|
|
|
|
|
|
|
# Default name
|
2023-01-24 03:38:41 +00:00
|
|
|
names.append((state.name, state.entity_id, context))
|
2023-01-07 21:20:21 +00:00
|
|
|
|
2023-01-29 12:16:29 +00:00
|
|
|
self._names_list = TextSlotList.from_tuples(names)
|
|
|
|
return self._names_list
|
2023-01-24 03:38:41 +00:00
|
|
|
|
|
|
|
def _get_error_text(
|
|
|
|
self, response_type: ResponseType, lang_intents: LanguageIntents
|
|
|
|
) -> str:
|
|
|
|
"""Get response error text by type."""
|
|
|
|
response_key = response_type.value
|
|
|
|
response_str = lang_intents.error_responses.get(response_key)
|
|
|
|
return response_str or _DEFAULT_ERROR_TEXT
|
|
|
|
|
|
|
|
|
|
|
|
def _make_error_result(
|
|
|
|
language: str,
|
|
|
|
error_code: intent.IntentResponseErrorCode,
|
|
|
|
response_text: str,
|
|
|
|
conversation_id: str | None = None,
|
|
|
|
) -> ConversationResult:
|
|
|
|
"""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)
|