464 lines
20 KiB
Python
464 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
import time
|
|
from typing import IO, Any, cast, Dict
|
|
import yaml
|
|
|
|
from home_assistant_intents import (
|
|
get_languages,
|
|
)
|
|
|
|
from hassil.intents import (
|
|
Intents,
|
|
TextSlotList,
|
|
)
|
|
from hassil.util import merge_dict
|
|
|
|
from homeassistant.const import EVENT_CALL_SERVICE
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.util import language as language_util
|
|
from homeassistant.util.json import JsonObjectType, json_loads_object
|
|
from homeassistant.components.conversation.const import (
|
|
SERVICE_RELOAD as CONVERSATION_SERVICE_RELOAD,
|
|
DOMAIN as DOMAIN_CONVERSATION,
|
|
)
|
|
|
|
import logging
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# get smartinspect logger reference; create a new session for this module name.
|
|
from smartinspectpython.siauto import SIAuto, SILevel, SISession, SIMethodParmListContext, SIColors
|
|
_logsi:SISession = SIAuto.Si.GetSession(__name__)
|
|
if (_logsi == None):
|
|
_logsi = SIAuto.Si.AddSession(__name__, True)
|
|
_logsi.SystemLogger = _LOGGER
|
|
|
|
ERROR_SENTINEL = object()
|
|
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
|
METADATA_CUSTOM_FILE = "hass_custom_file"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class LanguageIntents:
|
|
"""Loaded intents for a language."""
|
|
intents: Intents
|
|
intents_dict: dict[str, Any]
|
|
intent_responses: dict[str, Any]
|
|
error_responses: dict[str, Any]
|
|
platform_responses: dict[str, Any]
|
|
generic_responses: dict[str, Any]
|
|
language_variant: str | None
|
|
|
|
|
|
def json_load(fp: IO[str]) -> JsonObjectType:
|
|
"""Wrap json_loads for get_intents."""
|
|
return json_loads_object(fp.read())
|
|
|
|
|
|
class IntentLoader():
|
|
"""
|
|
Intent loader class.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
hass:HomeAssistant,
|
|
platform:str=None,
|
|
) -> None:
|
|
"""
|
|
Initialize a new instance of the class.
|
|
|
|
Args:
|
|
hass (HomeAssistant):
|
|
Hass instance.
|
|
platform (str):
|
|
Specify a platform prefix to only load `custom_sentences` files that
|
|
begin with the specified platform name.
|
|
"""
|
|
self.hass = hass
|
|
self._LangIntents:dict[str, LanguageIntents | object] = {}
|
|
self._LoadIntentsLock = asyncio.Lock()
|
|
self._Platform:str = platform
|
|
self.unsubscribe_event = None
|
|
|
|
|
|
@property
|
|
def supported_languages(self) -> list[str]:
|
|
"""Return a list of supported languages."""
|
|
return get_languages()
|
|
|
|
|
|
async def async_clear_cache(
|
|
self,
|
|
) -> None:
|
|
"""
|
|
Clears the cache of any loaded intents.
|
|
"""
|
|
try:
|
|
|
|
# anything in the cache? if not, then there's nothing to do.
|
|
if (self._LangIntents == {}):
|
|
_logsi.LogVerbose("Intent cache is already empty; nothing to do", colorValue=SIColors.Khaki)
|
|
return
|
|
|
|
# let's prepare to load the intent data;
|
|
# set a lock in case we get multiple requests at the same time.
|
|
_logsi.LogVerbose("Acquiring lock prior to clearing cache", colorValue=SIColors.Khaki)
|
|
async with self._LoadIntentsLock:
|
|
|
|
# clear the cache.
|
|
self._LangIntents.clear()
|
|
|
|
# trace.
|
|
_logsi.LogVerbose("Intent cache has been cleared", colorValue=SIColors.Khaki)
|
|
|
|
except Exception as ex:
|
|
|
|
# log exception, but not to system logger as HA will take care of it.
|
|
_logsi.LogException("Component async_clear_cache exception", ex, logToSystemLogger=False, colorValue=SIColors.Khaki)
|
|
raise
|
|
|
|
|
|
async def async_get_or_load_intents(
|
|
self,
|
|
language:str=None,
|
|
) -> LanguageIntents | None:
|
|
"""
|
|
Gets the cached intent data for the specified language.
|
|
|
|
Args:
|
|
language (str):
|
|
Language indicator, used to find the language-specific folder under the
|
|
custom_sentences base folder.
|
|
|
|
Returns:
|
|
A LanguageIntents instance if intent data was found; otherwise, None.
|
|
|
|
For the first call of this method, the intents are loaded from disk for the
|
|
specified language (in a thread-safe manner).
|
|
|
|
For subsequent calls, the cached intent list is returned.
|
|
|
|
The intent cache is cleared and reloaded if the conversation "reload" service
|
|
is called.
|
|
"""
|
|
try:
|
|
|
|
# validations.
|
|
if (not isinstance(language, str)) or (len(language.strip()) == 0):
|
|
language = self.hass.config.language or "en"
|
|
|
|
# if we already loaded intents, then return the cached data.
|
|
if lang_intents := self._LangIntents.get(language):
|
|
if lang_intents is ERROR_SENTINEL:
|
|
return None
|
|
return cast(LanguageIntents, lang_intents)
|
|
|
|
# trace.
|
|
_logsi.LogVerbose("Intents first-time load detected; calling load intents (language=%s)" % (language), colorValue=SIColors.Khaki)
|
|
|
|
# let's prepare to load the intent data;
|
|
# set a lock in case we get multiple requests at the same time.
|
|
_logsi.LogVerbose("Acquiring lock prior to loading intents", colorValue=SIColors.Khaki)
|
|
async with self._LoadIntentsLock:
|
|
|
|
# was another request waiting while we were loading?
|
|
# if so, then there is no need to load it again.
|
|
if lang_intents := self._LangIntents.get(language):
|
|
if lang_intents is ERROR_SENTINEL:
|
|
return None
|
|
return cast(LanguageIntents, lang_intents)
|
|
|
|
# get start time so we can see how long it takes to load.
|
|
start = time.monotonic()
|
|
|
|
# load the intents from custom sentences.
|
|
result = await self.hass.async_add_executor_job(
|
|
self._load_intent_definitions, language
|
|
)
|
|
|
|
# if we didn't find anything, then set result to an empty object.
|
|
# this denotes that we tried to load the intent data and it did not return
|
|
# anything, so that we don't try to load it again on the next request.
|
|
# otherwise, store the loaded intent data under the language-specific key.
|
|
if result is None:
|
|
self._LangIntents[language] = ERROR_SENTINEL
|
|
else:
|
|
self._LangIntents[language] = result
|
|
|
|
# trace.
|
|
_logsi.LogVerbose("Full intent load completed (language=%s) in %.2f seconds" % (language, (time.monotonic() - start)), colorValue=SIColors.Khaki)
|
|
|
|
# return result.
|
|
return result
|
|
|
|
except Exception as ex:
|
|
|
|
# log exception, but not to system logger as HA will take care of it.
|
|
_logsi.LogException("Component async_get_or_load_intents exception", ex, logToSystemLogger=False, colorValue=SIColors.Khaki)
|
|
raise
|
|
|
|
|
|
async def async_get_intent_list_byname(
|
|
self,
|
|
language:str=None,
|
|
listName:str=None,
|
|
) -> Dict[str, TextSlotList] | None:
|
|
"""
|
|
Gets the specified intent list name definition.
|
|
|
|
Args:
|
|
language (str):
|
|
Language indicator, used to find the language-specific folder under the
|
|
custom_sentences base folder.
|
|
listName (str):
|
|
Name of the specific list to retrieve; if null, ALL lists are returned.
|
|
|
|
Returns:
|
|
A dictionary of intent list data if found; otherwise, None.
|
|
"""
|
|
try:
|
|
|
|
# get intent cache; return null if intents could not be loaded.
|
|
langIntents:LanguageIntents = await self.async_get_or_load_intents(language)
|
|
if langIntents is None:
|
|
return None
|
|
|
|
# if list name not supplied, then return ALL lists.
|
|
if (listName is None):
|
|
return langIntents.intents.slot_lists
|
|
else:
|
|
return langIntents.intents.slot_lists.get(listName, None)
|
|
|
|
except Exception as ex:
|
|
|
|
# log exception, but not to system logger as HA will take care of it.
|
|
_logsi.LogException("Component async_get_intent_list exception", ex, logToSystemLogger=False, colorValue=SIColors.Khaki)
|
|
raise
|
|
|
|
|
|
def _load_intent_definitions(
|
|
self,
|
|
language:str=None,
|
|
) -> LanguageIntents | None:
|
|
"""
|
|
Loads language-specific intent template definitions from `custom_sentences/<lang>/*.yaml`
|
|
files (if found).
|
|
|
|
Must run inside executor!
|
|
|
|
Args:
|
|
language (str):
|
|
Language indicator, used to find the language-specific folder under the
|
|
custom_sentences base folder.
|
|
|
|
Returns:
|
|
A LanguageIntents object with loaded intent data if found; otherwise, None.
|
|
|
|
Response key templates may be nested using the following schemas:
|
|
- standard layout: `responses -> intents -> MyIntentName -> my_message_key: "My message text"`
|
|
- platform layout: `responses -> MyPlatform -> my_message_key: "My message text"`
|
|
- flatfile layout: `responses -> my_message_key: "My message text"`
|
|
"""
|
|
methodParms:SIMethodParmListContext = None
|
|
|
|
try:
|
|
|
|
# trace.
|
|
methodParms = _logsi.EnterMethodParmList(SILevel.Debug, colorValue=SIColors.Khaki)
|
|
methodParms.AppendKeyValue("language", str(language))
|
|
methodParms.AppendKeyValue("platform", self._Platform)
|
|
_logsi.LogMethodParmList(SILevel.Verbose, "Component _load_intent_definitions starting", methodParms, colorValue=SIColors.Khaki)
|
|
|
|
intents_dict: dict[str, Any] = {}
|
|
supported_langs = set(get_languages())
|
|
|
|
# choose a language variant upfront and commit to it.
|
|
lang_matches = language_util.matches(language, supported_langs)
|
|
if not lang_matches:
|
|
_logsi.LogWarning("Unable to find supported language variant for \"%s\"" % language)
|
|
return None
|
|
|
|
language_variant = lang_matches[0]
|
|
|
|
# # load built-in intents and responses for this language variant.
|
|
# lang_variant_intents = get_intents(language_variant, json_load=json_load)
|
|
|
|
# if lang_variant_intents:
|
|
# # merge sentences into existing dictionary
|
|
# # overriding because source dict is empty
|
|
# intents_dict = lang_variant_intents
|
|
|
|
# _LOGGER.debug(
|
|
# "Loaded built-in intents for language=%s (%s)",
|
|
# language,
|
|
# language_variant,
|
|
# )
|
|
|
|
# check for custom sentences in "<config>/custom_sentences/<language>/" path.
|
|
custom_sentences_dir = Path(
|
|
self.hass.config.path("custom_sentences", language_variant)
|
|
)
|
|
_logsi.LogVerbose("Checking custom_sentences directory: \"%s\"" % custom_sentences_dir, colorValue=SIColors.Khaki)
|
|
|
|
# is this a directory?
|
|
if custom_sentences_dir.is_dir():
|
|
|
|
# check for platform prefix file limiting.
|
|
platformPfx = ""
|
|
if self._Platform is not None:
|
|
platformPfx = f"{self._Platform}_"
|
|
_logsi.LogVerbose("Limiting custom_sentences files by platform prefix: \"%s\"" % platformPfx, colorValue=SIColors.Khaki)
|
|
|
|
# create file search pattern.
|
|
fileSearchPattern = f"{platformPfx}*.yaml"
|
|
_logsi.LogVerbose("Searching custom_sentences directory (and sub-directories) for this file pattern: \"%s\"" % fileSearchPattern, colorValue=SIColors.Khaki)
|
|
|
|
# process all found files in the directory, including sub-directories.
|
|
for custom_sentences_path in sorted(custom_sentences_dir.rglob(fileSearchPattern)):
|
|
|
|
# if processing only our platform files, then disacrd files that aren't ours.
|
|
# this assumes that our files are prefixed by the platform name (e.g. "/spotifyplus_IntentNamexxx.yaml").
|
|
# TODO should not need this, as the rglob takes care of filtering out unwanted files!
|
|
#fnameCompare = custom_sentences_path.name.lower()
|
|
# if (self._Platform is not None) and (not fnameCompare.startswith(self._Platform)):
|
|
# _logsi.LogDebug("Discarding non-platform custom_sentences file: %s" % custom_sentences_path, colorValue=SIColors.Khaki)
|
|
# continue
|
|
|
|
# process the file.
|
|
_logsi.LogVerbose("Loading custom_sentences file: %s" % custom_sentences_path, colorValue=SIColors.Khaki)
|
|
with custom_sentences_path.open(encoding="utf-8") as custom_sentences_file:
|
|
|
|
# merge custom sentences.
|
|
if not isinstance(
|
|
custom_sentences_yaml := yaml.safe_load(custom_sentences_file),
|
|
dict,
|
|
):
|
|
_logsi.LogWarning("Custom sentences file does not match expected format: \"%s\"" % custom_sentences_file.name)
|
|
continue
|
|
|
|
# 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 dictionary into base dictionary.
|
|
merge_dict(intents_dict, custom_sentences_yaml)
|
|
|
|
# if no custom sentences defined then we are done.
|
|
if not intents_dict:
|
|
_logsi.LogVerbose("Could not find custom_sentences files for language=\"%s\", platform=\"%s\"" % (language, self._Platform), colorValue=SIColors.Khaki)
|
|
return None
|
|
|
|
# load intent objects from dictionary format.
|
|
intents = Intents.from_dict(intents_dict)
|
|
|
|
# load responses.
|
|
responses_dict = intents_dict.get("responses", {})
|
|
intent_responses = responses_dict.get("intents", {})
|
|
platform_responses = responses_dict.get(self._Platform, {})
|
|
error_responses = responses_dict.get("errors", {})
|
|
|
|
# remove any child dictionary items from generic.
|
|
generic_responses = copy.deepcopy(intents_dict.get("responses", {}))
|
|
for key in [k for k, v in generic_responses.items() if isinstance(v, dict)]:
|
|
del generic_responses[key]
|
|
|
|
# trace.
|
|
if (_logsi.IsOn(SILevel.Verbose)):
|
|
_logsi.LogDictionary(SILevel.Verbose, "Component intents information: %s (%s) - all (dictionary)" % (self._Platform, language_variant), intents_dict, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
_logsi.LogDictionary(SILevel.Verbose, "Component intents information: %s (%s) - responses (dictionary)" % (self._Platform, language_variant), responses_dict, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
_logsi.LogDictionary(SILevel.Verbose, "Component intents information: %s (%s) - intent_responses (dictionary)" % (self._Platform, language_variant), intent_responses, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
_logsi.LogDictionary(SILevel.Verbose, "Component intents information: %s (%s) - platform_responses (dictionary)" % (self._Platform, language_variant), platform_responses, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
_logsi.LogDictionary(SILevel.Verbose, "Component intents information: %s (%s) - error_responses (dictionary)" % (self._Platform, language_variant), error_responses, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
_logsi.LogDictionary(SILevel.Verbose, "Component intents information: %s (%s) - generic_responses (dictionary)" % (self._Platform, language_variant), generic_responses, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
_logsi.LogDictionary(SILevel.Verbose, "Component intents information: %s (%s) - intents slot_lists (dictionary)" % (self._Platform, language_variant), intents.slot_lists, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
_logsi.LogDictionary(SILevel.Verbose, "Component intents information: %s (%s) - intents expansion_rules (dictionary)" % (self._Platform, language_variant), intents.expansion_rules, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
_logsi.LogDictionary(SILevel.Verbose, "Component intents information: %s (%s) - intents skip_words (dictionary)" % (self._Platform, language_variant), intents.skip_words, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
|
|
# return results.
|
|
result = LanguageIntents(
|
|
intents,
|
|
intents_dict,
|
|
intent_responses,
|
|
error_responses,
|
|
platform_responses,
|
|
generic_responses,
|
|
language_variant,
|
|
)
|
|
|
|
# trace.
|
|
_logsi.LogVerbose("Component _load_intent_definitions complete", colorValue=SIColors.Khaki)
|
|
return result
|
|
|
|
except Exception as ex:
|
|
|
|
# log exception, but not to system logger as HA will take care of it.
|
|
_logsi.LogException("Component _load_intent_definitions exception", ex, logToSystemLogger=False, colorValue=SIColors.Khaki)
|
|
raise
|
|
|
|
finally:
|
|
|
|
# trace.
|
|
_logsi.LeaveMethod(SILevel.Debug, colorValue=SIColors.Khaki)
|
|
|
|
|
|
async def async_register_cache_reload_listener(
|
|
self,
|
|
) -> None:
|
|
"""
|
|
Registers an event listener that listens for `conversation.reload` service calls,
|
|
so that our cache will also be reloaded on the next access call.
|
|
"""
|
|
|
|
@callback
|
|
async def _handle_call_service_event(event):
|
|
""" Called when a "call_service" event is detected. """
|
|
|
|
# get event data.
|
|
data = event.data
|
|
domain = data.get("domain")
|
|
service = data.get("service")
|
|
|
|
# trace.
|
|
#_logsi.LogDictionary(SILevel.Verbose, "Call Service detected: domain=\"%s\", service=\"%s\"" % (domain, service), event, prettyPrint=True, colorValue=SIColors.Red)
|
|
|
|
# was the "conversation.reload" service called?
|
|
if domain == DOMAIN_CONVERSATION and service == CONVERSATION_SERVICE_RELOAD:
|
|
|
|
_logsi.LogDictionary(SILevel.Verbose, "Intent Loader detected a Conversation integration Reload event", event, prettyPrint=True, colorValue=SIColors.Khaki)
|
|
|
|
# clear our intent cache, so it will be reloaded next time.
|
|
await self.async_clear_cache()
|
|
|
|
# listen for all call service events, and store unsubscribe function.
|
|
self.unsubscribe_event = self.hass.bus.async_listen(EVENT_CALL_SERVICE, _handle_call_service_event)
|
|
|
|
# trace.
|
|
_logsi.LogVerbose("Intent Loader registered the conversation reload event listener", colorValue=SIColors.Khaki)
|
|
|
|
|
|
async def async_unregister_cache_reload_listener(
|
|
self,
|
|
) -> None:
|
|
"""
|
|
Unregisters an event listener that listens for `conversation.reload` service calls.
|
|
"""
|
|
# did we register an event listener? if so, then call it's unregister function.
|
|
if self.unsubscribe_event:
|
|
self.unsubscribe_event() # <-- stop listening
|
|
_logsi.LogVerbose("Unregistered the conversation reload event listener", colorValue=SIColors.Khaki)
|