Files
HomeAssistance/custom_components/spotifyplus/intent_loader.py
T
2026-06-05 22:34:31 -04:00

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)