mirror of
https://github.com/home-assistant/core.git
synced 2025-12-02 05:58:04 +00:00
Compare commits
2 Commits
add_presen
...
synesthesi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1bd35b20b | ||
|
|
2f2aee93c7 |
@@ -4,11 +4,17 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from hassil.intents import Intents, WildcardSlotList
|
||||
from hassil.recognize import RecognizeResult
|
||||
from hassil.util import merge_dict
|
||||
from home_assistant_intents import get_intents, get_languages
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
from homeassistant.components.homeassistant.exposed_entities import async_should_expose
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import (
|
||||
@@ -19,11 +25,18 @@ from homeassistant.core import (
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
config_validation as cv,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
intent,
|
||||
)
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util import language as language_util
|
||||
|
||||
from .agent_manager import (
|
||||
AgentInfo,
|
||||
@@ -50,16 +63,19 @@ from .const import (
|
||||
ATTR_CONVERSATION_ID,
|
||||
ATTR_LANGUAGE,
|
||||
ATTR_TEXT,
|
||||
CUSTOM_SENTENCES_DIR_NAME,
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
HOME_ASSISTANT_AGENT,
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
SENTENCE_TRIGGER_INTENT_NAME,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
IntentSource,
|
||||
)
|
||||
from .default_agent import async_setup_default_agent
|
||||
from .default_agent import async_setup_default_agent, collect_list_references
|
||||
from .entity import ConversationEntity
|
||||
from .http import async_setup as async_setup_conversation_http
|
||||
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||
@@ -264,6 +280,121 @@ async def async_handle_intents(
|
||||
return await agent.async_handle_intents(user_input, intent_filter=intent_filter)
|
||||
|
||||
|
||||
async def async_get_intents(
|
||||
hass: HomeAssistant, language: str, source: IntentSource = IntentSource.ALL
|
||||
) -> Intents:
|
||||
"""Load intents for a language."""
|
||||
intents_dict: dict[str, Any] = {"intents": {}, "lists": {}}
|
||||
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
if source & IntentSource.BUILTIN_SENTENCES:
|
||||
# From home-assistant-intents package
|
||||
supported_langs = set(get_languages())
|
||||
lang_matches = language_util.matches(language, supported_langs)
|
||||
if lang_matches and (lang_intents := get_intents(lang_matches[0])):
|
||||
intents_dict = lang_intents
|
||||
|
||||
if source & IntentSource.CUSTOM_SENTENCES:
|
||||
# From config/custom_sentences
|
||||
|
||||
def load_custom_sentences():
|
||||
"""Merge custom sentences for matching language only."""
|
||||
base_dir = Path(hass.config.path(CUSTOM_SENTENCES_DIR_NAME))
|
||||
supported_langs = {d.name for d in base_dir.iterdir() if d.is_dir()}
|
||||
lang_matches = language_util.matches(language, supported_langs)
|
||||
if not lang_matches:
|
||||
return
|
||||
|
||||
sentences_dir = base_dir / lang_matches[0]
|
||||
for sentences_path in sentences_dir.rglob("*.yaml"):
|
||||
with open(sentences_path, encoding="utf-8") as sentences_file:
|
||||
merge_dict(intents_dict, yaml.safe_load(sentences_file))
|
||||
|
||||
# Do I/O in executor
|
||||
await hass.async_add_executor_job(load_custom_sentences)
|
||||
|
||||
if (source & IntentSource.CONVERSATION_CONFIG) and agent.config_intents:
|
||||
# From conversation YAML config
|
||||
merge_dict(intents_dict, agent.config_intents)
|
||||
|
||||
if (source & IntentSource.SENTENCE_TRIGGERS) and agent.trigger_details:
|
||||
# From automations
|
||||
merge_dict(
|
||||
intents_dict,
|
||||
{
|
||||
"intents": {
|
||||
SENTENCE_TRIGGER_INTENT_NAME: {
|
||||
"data": [
|
||||
{"sentences": trigger_details.sentences}
|
||||
for trigger_details in agent.trigger_details
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Add exposed entities + areas/floors
|
||||
entity_tuples = []
|
||||
entity_registry = er.async_get(hass)
|
||||
for state in hass.states.async_all():
|
||||
if not async_should_expose(hass, DOMAIN, state.entity_id):
|
||||
continue
|
||||
|
||||
context: dict[str, Any] = {"domain": state.domain}
|
||||
entity_tuples.append((state.name, context))
|
||||
|
||||
if not (entity := entity_registry.async_get(state.entity_id)):
|
||||
continue
|
||||
|
||||
entity_tuples.extend(
|
||||
(alias, context)
|
||||
for alias in filter(None, (a.strip() for a in entity.aliases))
|
||||
)
|
||||
|
||||
intents_dict["lists"]["name"] = {
|
||||
"values": [
|
||||
{"in": name, "out": name, "context": context}
|
||||
for name, context in entity_tuples
|
||||
]
|
||||
}
|
||||
|
||||
# Areas/floors
|
||||
area_registry = ar.async_get(hass)
|
||||
floor_registry = fr.async_get(hass)
|
||||
for list_name, entries in (
|
||||
("area", area_registry.async_list_areas()),
|
||||
("floor", floor_registry.async_list_floors()),
|
||||
):
|
||||
entry_names = set()
|
||||
for entry in entries:
|
||||
entry_names.add(entry.name)
|
||||
entry_names.update(filter(None, (a.strip() for a in entry.aliases)))
|
||||
|
||||
if entry_names:
|
||||
intents_dict["lists"][list_name] = {"values": list(entry_names)}
|
||||
|
||||
# Parse and post-process
|
||||
intents_dict["language"] = language
|
||||
intents = Intents.from_dict(intents_dict)
|
||||
|
||||
if SENTENCE_TRIGGER_INTENT_NAME in intents.intents:
|
||||
# Unknown lists in sentence triggers are wildcards
|
||||
wildcard_names: set[str] = set()
|
||||
for intent_data in intents.intents[SENTENCE_TRIGGER_INTENT_NAME].data:
|
||||
for sentence in intent_data.sentences:
|
||||
collect_list_references(sentence.expression, wildcard_names)
|
||||
|
||||
for wildcard_name in wildcard_names:
|
||||
if wildcard_name in intents.slot_lists:
|
||||
continue
|
||||
|
||||
intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
|
||||
|
||||
return intents
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Register the process service."""
|
||||
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
|
||||
|
||||
@@ -34,3 +34,23 @@ class ConversationEntityFeature(IntFlag):
|
||||
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
SENTENCE_TRIGGER_INTENT_NAME = "HassSentenceTrigger"
|
||||
CUSTOM_SENTENCES_DIR_NAME = "custom_sentences"
|
||||
|
||||
|
||||
class IntentSource(IntFlag):
|
||||
"""Source of intents and sentence templates."""
|
||||
|
||||
SENTENCE_TRIGGERS = 1
|
||||
"""Sentence triggers in automations."""
|
||||
|
||||
CONVERSATION_CONFIG = 2
|
||||
"""YAML configuration for conversation component."""
|
||||
|
||||
CUSTOM_SENTENCES = 4
|
||||
"""YAML files in config/custom_sentences."""
|
||||
|
||||
BUILTIN_SENTENCES = 8
|
||||
"""Sentences from home-assistant-intents package."""
|
||||
|
||||
ALL = SENTENCE_TRIGGERS | CONVERSATION_CONFIG | CUSTOM_SENTENCES | BUILTIN_SENTENCES
|
||||
|
||||
@@ -78,6 +78,7 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
from .agent_manager import get_agent_manager
|
||||
from .chat_log import AssistantContent, ChatLog
|
||||
from .const import (
|
||||
CUSTOM_SENTENCES_DIR_NAME,
|
||||
DOMAIN,
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
@@ -265,6 +266,16 @@ class DefaultAgent(ConversationEntity):
|
||||
"""Return a list of supported languages."""
|
||||
return get_languages()
|
||||
|
||||
@property
|
||||
def trigger_details(self) -> list[TriggerDetails]:
|
||||
"""Get sentence trigger details."""
|
||||
return self._triggers_details
|
||||
|
||||
@property
|
||||
def config_intents(self) -> dict[str, Any]:
|
||||
"""Get intents from conversation configuration."""
|
||||
return self._config_intents
|
||||
|
||||
@callback
|
||||
def _filter_entity_registry_changes(
|
||||
self, event_data: er.EventEntityRegistryUpdatedData
|
||||
@@ -1128,7 +1139,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
# Check for custom sentences in <config>/custom_sentences/<language>/
|
||||
custom_sentences_dir = Path(
|
||||
self.hass.config.path("custom_sentences", language_variant)
|
||||
self.hass.config.path(CUSTOM_SENTENCES_DIR_NAME, language_variant)
|
||||
)
|
||||
if custom_sentences_dir.is_dir():
|
||||
for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"):
|
||||
@@ -1467,7 +1478,7 @@ class DefaultAgent(ConversationEntity):
|
||||
for trigger_intent in trigger_intents.intents.values():
|
||||
for intent_data in trigger_intent.data:
|
||||
for sentence in intent_data.sentences:
|
||||
_collect_list_references(sentence.expression, wildcard_names)
|
||||
collect_list_references(sentence.expression, wildcard_names)
|
||||
|
||||
for wildcard_name in wildcard_names:
|
||||
trigger_intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
|
||||
@@ -1798,11 +1809,11 @@ def _get_match_error_response(
|
||||
return ErrorKey.NO_INTENT, {}
|
||||
|
||||
|
||||
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
|
||||
def collect_list_references(expression: Expression, list_names: set[str]) -> None:
|
||||
"""Collect list reference names recursively."""
|
||||
if isinstance(expression, Group):
|
||||
for item in expression.items:
|
||||
_collect_list_references(item, list_names)
|
||||
collect_list_references(item, list_names)
|
||||
elif isinstance(expression, ListReference):
|
||||
# {list}
|
||||
list_names.add(expression.slot_name)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
from hassil.expression import TextChunk
|
||||
from hassil.intents import TextSlotList
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
import voluptuous as vol
|
||||
@@ -10,16 +12,24 @@ import voluptuous as vol
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.conversation import (
|
||||
ConversationInput,
|
||||
IntentSource,
|
||||
async_get_agent,
|
||||
async_handle_intents,
|
||||
async_handle_sentence_triggers,
|
||||
default_agent,
|
||||
)
|
||||
from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT
|
||||
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
intent,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import MockAgent
|
||||
@@ -357,3 +367,149 @@ async def test_async_handle_intents(hass: HomeAssistant) -> None:
|
||||
),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", list(IntentSource))
|
||||
async def test_async_get_intents(hass: HomeAssistant, source: IntentSource) -> None:
|
||||
"""Test getting available intents."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"conversation",
|
||||
{"conversation": {"intents": {"StealthMode": ["engage stealth mode"]}}},
|
||||
)
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"automation",
|
||||
{
|
||||
"automation": {
|
||||
"trigger": {
|
||||
"platform": "conversation",
|
||||
"command": ["my trigger"],
|
||||
},
|
||||
"action": {
|
||||
"set_conversation_response": "my response",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# All sources
|
||||
intents = await conversation.async_get_intents(hass, "en", source=source)
|
||||
|
||||
expected_intents: set[str] = set()
|
||||
unexpected_intents: set[str] = set()
|
||||
|
||||
if source & IntentSource.BUILTIN_SENTENCES:
|
||||
expected_intents.add("HassTurnOn")
|
||||
else:
|
||||
unexpected_intents.add("HassTurnOn")
|
||||
|
||||
if source & IntentSource.CUSTOM_SENTENCES:
|
||||
expected_intents.add("OrderBeer")
|
||||
else:
|
||||
unexpected_intents.add("OrderBeer")
|
||||
|
||||
if source & IntentSource.CONVERSATION_CONFIG:
|
||||
expected_intents.add("StealthMode")
|
||||
else:
|
||||
unexpected_intents.add("StealthMode")
|
||||
|
||||
if source & IntentSource.SENTENCE_TRIGGERS:
|
||||
expected_intents.add(conversation.SENTENCE_TRIGGER_INTENT_NAME)
|
||||
else:
|
||||
unexpected_intents.add(conversation.SENTENCE_TRIGGER_INTENT_NAME)
|
||||
|
||||
# Verify expected intents exist and have sentences
|
||||
for intent_name in expected_intents:
|
||||
assert intent_name in intents.intents
|
||||
assert (
|
||||
sum(
|
||||
len(intent_data.sentences)
|
||||
for intent_data in intents.intents[intent_name].data
|
||||
)
|
||||
> 0
|
||||
)
|
||||
|
||||
# Verify unexpected intents don't exist
|
||||
for intent_name in unexpected_intents:
|
||||
assert intent_name not in intents.intents
|
||||
|
||||
|
||||
async def test_async_get_intents_bad_language(hass: HomeAssistant) -> None:
|
||||
"""Test getting available intents with a non-existent language."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "conversation", {})
|
||||
|
||||
intents = await conversation.async_get_intents(hass, "does-not-exist")
|
||||
assert not intents.intents
|
||||
|
||||
|
||||
async def test_async_get_intents_language_region(hass: HomeAssistant) -> None:
|
||||
"""Test getting available intents with a regional language code."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "conversation", {})
|
||||
|
||||
intents = await conversation.async_get_intents(hass, "en-US")
|
||||
|
||||
# built-in and custom sentences for "en" are included
|
||||
for intent_name in ("HassTurnOn", "OrderBeer"):
|
||||
assert intent_name in intents.intents
|
||||
|
||||
|
||||
async def test_async_get_intents_lists(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
area_registry: ar.AreaRegistry,
|
||||
floor_registry: fr.FloorRegistry,
|
||||
) -> None:
|
||||
"""Test that intents contain name/area/floor lists."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "conversation", {})
|
||||
|
||||
# Create a light in an area on a floor
|
||||
floor_ground = floor_registry.async_create("ground")
|
||||
area_kitchen = area_registry.async_get_or_create("kitchen_id")
|
||||
area_kitchen = area_registry.async_update(
|
||||
area_kitchen.id, name="kitchen", floor_id=floor_ground.floor_id
|
||||
)
|
||||
kitchen_light = entity_registry.async_get_or_create("light", "demo", "light1234")
|
||||
kitchen_light = entity_registry.async_update_entity(
|
||||
kitchen_light.entity_id, area_id=area_kitchen.id
|
||||
)
|
||||
hass.states.async_set(
|
||||
kitchen_light.entity_id, "off", {ATTR_FRIENDLY_NAME: "demo light"}
|
||||
)
|
||||
|
||||
# Add a second unexposed light
|
||||
unexposed_light = entity_registry.async_get_or_create("light", "demo", "light5678")
|
||||
unexposed_light = entity_registry.async_update_entity(
|
||||
unexposed_light.entity_id, area_id=area_kitchen.id
|
||||
)
|
||||
hass.states.async_set(
|
||||
unexposed_light.entity_id, "off", {ATTR_FRIENDLY_NAME: "unexposed light"}
|
||||
)
|
||||
async_expose_entity(hass, conversation.DOMAIN, unexposed_light.entity_id, False)
|
||||
|
||||
# name/area/floor lists should reflect state of registries
|
||||
intents = await conversation.async_get_intents(hass, "en")
|
||||
|
||||
name_list = intents.slot_lists.get("name")
|
||||
assert isinstance(name_list, TextSlotList)
|
||||
|
||||
# 1 exposed light
|
||||
assert len(name_list.values) == 1
|
||||
assert name_list.values[0].text_in == TextChunk("demo light")
|
||||
assert name_list.values[0].context == {"domain": "light"}
|
||||
|
||||
# 1 area
|
||||
area_list = intents.slot_lists.get("area")
|
||||
assert isinstance(area_list, TextSlotList)
|
||||
assert len(area_list.values) == 1
|
||||
assert area_list.values[0].text_in == TextChunk("kitchen")
|
||||
|
||||
# 1 floor
|
||||
floor_list = intents.slot_lists.get("floor")
|
||||
assert isinstance(floor_list, TextSlotList)
|
||||
assert len(floor_list.values) == 1
|
||||
assert floor_list.values[0].text_in == TextChunk("ground")
|
||||
|
||||
Reference in New Issue
Block a user