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-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-07 21:20:21 +00:00
|
|
|
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
|
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-07 21:20:21 +00:00
|
|
|
from homeassistant.helpers import area_registry, entity_registry, intent
|
2019-10-18 18:46:45 +00:00
|
|
|
|
2022-12-13 22:46:40 +00:00
|
|
|
from .agent import AbstractConversationAgent, 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__)
|
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-07 21:20:21 +00:00
|
|
|
@dataclass
|
|
|
|
class LanguageIntents:
|
|
|
|
"""Loaded intents for a language."""
|
|
|
|
|
|
|
|
intents: Intents
|
|
|
|
intents_dict: dict[str, Any]
|
|
|
|
loaded_components: set[str]
|
|
|
|
|
|
|
|
|
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-19 23:15:01 +00:00
|
|
|
async def async_initialize(self):
|
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-19 18:59:02 +00:00
|
|
|
self.hass.data.setdefault(DOMAIN, {})
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
async def async_process(
|
|
|
|
self,
|
|
|
|
text: str,
|
|
|
|
context: core.Context,
|
|
|
|
conversation_id: str | None = None,
|
|
|
|
language: str | None = None,
|
|
|
|
) -> ConversationResult | None:
|
|
|
|
"""Process a sentence."""
|
|
|
|
language = language or self.hass.config.language
|
|
|
|
lang_intents = self._lang_intents.get(language)
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
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(
|
2019-10-18 18:46:45 +00:00
|
|
|
self.hass,
|
2023-01-07 21:20:21 +00:00
|
|
|
DOMAIN,
|
|
|
|
result.intent.name,
|
|
|
|
{entity.name: {"value": entity.value} for entity in result.entities_list},
|
|
|
|
text,
|
|
|
|
context,
|
|
|
|
language,
|
2019-10-18 18:46:45 +00:00
|
|
|
)
|
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-07 21:20:21 +00:00
|
|
|
# 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
|
2023-01-09 22:48:59 +00:00
|
|
|
_LOGGER.debug(
|
|
|
|
"Loaded intents component=%s, language=%s", component, language
|
|
|
|
)
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
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
|
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:
|
|
|
|
names.append((alias, state.entity_id))
|
|
|
|
|
|
|
|
# Default name
|
|
|
|
names.append((state.name, state.entity_id))
|
|
|
|
|
|
|
|
return TextSlotList.from_tuples(names)
|