Compare commits

...

2 Commits

Author SHA1 Message Date
Michael Hansen
d1bd35b20b Add name/area/floor lists 2025-11-11 14:17:26 -06:00
Michael Hansen
2f2aee93c7 Add async_get_intents 2025-11-11 11:36:49 -06:00
4 changed files with 325 additions and 7 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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")