Home Assistant Unofficial Reference 2024.12.1
default_agent.py
Go to the documentation of this file.
1 """Standard conversation implementation for Home Assistant."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections import OrderedDict
7 from collections.abc import Awaitable, Callable, Iterable
8 from dataclasses import dataclass
9 from enum import Enum, auto
10 import functools
11 import logging
12 from pathlib import Path
13 import re
14 import time
15 from typing import IO, Any, cast
16 
17 from hassil.expression import Expression, ListReference, Sequence, TextChunk
18 from hassil.intents import (
19  Intents,
20  SlotList,
21  TextSlotList,
22  TextSlotValue,
23  WildcardSlotList,
24 )
25 from hassil.recognize import (
26  MISSING_ENTITY,
27  RecognizeResult,
28  recognize_all,
29  recognize_best,
30 )
31 from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
32 from hassil.trie import Trie
33 from hassil.util import merge_dict
34 from home_assistant_intents import ErrorKey, get_intents, get_languages
35 import yaml
36 
37 from homeassistant import core
39  async_listen_entity_updates,
40  async_should_expose,
41 )
42 from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
43 from homeassistant.helpers import (
44  area_registry as ar,
45  device_registry as dr,
46  entity_registry as er,
47  floor_registry as fr,
48  intent,
49  start as ha_start,
50  template,
51  translation,
52 )
53 from homeassistant.helpers.entity_component import EntityComponent
54 from homeassistant.helpers.event import async_track_state_added_domain
55 from homeassistant.util.json import JsonObjectType, json_loads_object
56 
57 from .const import (
58  DATA_DEFAULT_ENTITY,
59  DEFAULT_EXPOSED_ATTRIBUTES,
60  DOMAIN,
61  ConversationEntityFeature,
62 )
63 from .entity import ConversationEntity
64 from .models import ConversationInput, ConversationResult
65 from .trace import ConversationTraceEventType, async_conversation_trace_append
66 
67 _LOGGER = logging.getLogger(__name__)
68 _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
69 _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
70 
71 REGEX_TYPE = type(re.compile(""))
72 TRIGGER_CALLBACK_TYPE = Callable[
73  [str, RecognizeResult, str | None], Awaitable[str | None]
74 ]
75 METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
76 METADATA_CUSTOM_FILE = "hass_custom_file"
77 
78 ERROR_SENTINEL = object()
79 
80 
81 def json_load(fp: IO[str]) -> JsonObjectType:
82  """Wrap json_loads for get_intents."""
83  return json_loads_object(fp.read())
84 
85 
86 @dataclass(slots=True)
88  """Loaded intents for a language."""
89 
90  intents: Intents
91  intents_dict: dict[str, Any]
92  intent_responses: dict[str, Any]
93  error_responses: dict[str, Any]
94  language_variant: str | None
95 
96 
97 @dataclass(slots=True)
99  """List of sentences and the callback for a trigger."""
100 
101  sentences: list[str]
102  callback: TRIGGER_CALLBACK_TYPE
103 
104 
105 @dataclass(slots=True)
107  """Result when matching a sentence trigger in an automation."""
108 
109  sentence: str
110  sentence_template: str | None
111  matched_triggers: dict[int, RecognizeResult]
112 
113 
115  """Stages of intent matching."""
116 
117  EXPOSED_ENTITIES_ONLY = auto()
118  """Match against exposed entities only."""
119 
120  UNEXPOSED_ENTITIES = auto()
121  """Match against unexposed entities in Home Assistant."""
122 
123  FUZZY = auto()
124  """Capture names that are not known to Home Assistant."""
125 
126 
127 @dataclass(frozen=True)
129  """Key for IntentCache."""
130 
131  text: str
132  """User input text."""
133 
134  language: str
135  """Language of text."""
136 
137  device_id: str | None
138  """Device id from user input."""
139 
140 
141 @dataclass(frozen=True)
143  """Value for IntentCache."""
144 
145  result: RecognizeResult | None
146  """Result of intent recognition."""
147 
148  stage: IntentMatchingStage
149  """Stage where result was found."""
150 
151 
153  """LRU cache for intent recognition results."""
154 
155  def __init__(self, capacity: int) -> None:
156  """Initialize cache."""
157  self.cache: OrderedDict[IntentCacheKey, IntentCacheValue] = OrderedDict()
158  self.capacitycapacity = capacity
159 
160  def get(self, key: IntentCacheKey) -> IntentCacheValue | None:
161  """Get value for cache or None."""
162  if key not in self.cache:
163  return None
164 
165  # Move the key to the end to show it was recently used
166  self.cache.move_to_end(key)
167  return self.cache[key]
168 
169  def put(self, key: IntentCacheKey, value: IntentCacheValue) -> None:
170  """Put a value in the cache, evicting the least recently used item if necessary."""
171  if key in self.cache:
172  # Update value and mark as recently used
173  self.cache.move_to_end(key)
174  elif len(self.cache) >= self.capacitycapacity:
175  # Evict the oldest item
176  self.cache.popitem(last=False)
177 
178  self.cache[key] = value
179 
180  def clear(self) -> None:
181  """Clear the cache."""
182  self.cache.clear()
183 
184 
185 def _get_language_variations(language: str) -> Iterable[str]:
186  """Generate language codes with and without region."""
187  yield language
188 
189  parts = re.split(r"([-_])", language)
190  if len(parts) == 3:
191  lang, sep, region = parts
192  if sep == "_":
193  # en_US -> en-US
194  yield f"{lang}-{region}"
195 
196  # en-US -> en
197  yield lang
198 
199 
201  hass: core.HomeAssistant,
202  entity_component: EntityComponent[ConversationEntity],
203  config_intents: dict[str, Any],
204 ) -> None:
205  """Set up entity registry listener for the default agent."""
206  entity = DefaultAgent(hass, config_intents)
207  await entity_component.async_add_entities([entity])
208  hass.data[DATA_DEFAULT_ENTITY] = entity
209 
210  @core.callback
211  def async_entity_state_listener(
212  event: core.Event[core.EventStateChangedData],
213  ) -> None:
214  """Set expose flag on new entities."""
215  async_should_expose(hass, DOMAIN, event.data["entity_id"])
216 
217  @core.callback
218  def async_hass_started(hass: core.HomeAssistant) -> None:
219  """Set expose flag on all entities."""
220  for state in hass.states.async_all():
221  async_should_expose(hass, DOMAIN, state.entity_id)
222  async_track_state_added_domain(hass, MATCH_ALL, async_entity_state_listener)
223 
224  ha_start.async_at_started(hass, async_hass_started)
225 
226 
228  """Default agent for conversation agent."""
229 
230  _attr_name = "Home Assistant"
231  _attr_supported_features = ConversationEntityFeature.CONTROL
232 
233  def __init__(
234  self, hass: core.HomeAssistant, config_intents: dict[str, Any]
235  ) -> None:
236  """Initialize the default agent."""
237  self.hasshasshass = hass
238  self._lang_intents: dict[str, LanguageIntents | object] = {}
239 
240  # intent -> [sentences]
241  self._config_intents: dict[str, Any] = config_intents
242  self._slot_lists_slot_lists: dict[str, SlotList] | None = None
243 
244  # Used to filter slot lists before intent matching
245  self._exposed_names_trie_exposed_names_trie: Trie | None = None
246  self._unexposed_names_trie_unexposed_names_trie: Trie | None = None
247 
248  # Sentences that will trigger a callback (skipping intent recognition)
249  self._trigger_sentences: list[TriggerData] = []
250  self._trigger_intents_trigger_intents: Intents | None = None
251  self._unsub_clear_slot_list_unsub_clear_slot_list: list[Callable[[], None]] | None = None
252  self._load_intents_lock_load_intents_lock = asyncio.Lock()
253 
254  # LRU cache to avoid unnecessary intent matching
255  self._intent_cache_intent_cache = IntentCache(capacity=128)
256 
257  @property
258  def supported_languages(self) -> list[str]:
259  """Return a list of supported languages."""
260  return get_languages()
261 
262  @core.callback
264  self, event_data: er.EventEntityRegistryUpdatedData
265  ) -> bool:
266  """Filter entity registry changed events."""
267  return event_data["action"] == "update" and any(
268  field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
269  )
270 
271  @core.callback
272  def _filter_state_changes(self, event_data: core.EventStateChangedData) -> bool:
273  """Filter state changed events."""
274  return not event_data["old_state"] or not event_data["new_state"]
275 
276  @core.callback
277  def _listen_clear_slot_list(self) -> None:
278  """Listen for changes that can invalidate slot list."""
279  assert self._unsub_clear_slot_list_unsub_clear_slot_list is None
280 
281  self._unsub_clear_slot_list_unsub_clear_slot_list = [
282  self.hasshasshass.bus.async_listen(
283  ar.EVENT_AREA_REGISTRY_UPDATED,
284  self._async_clear_slot_list_async_clear_slot_list,
285  ),
286  self.hasshasshass.bus.async_listen(
287  fr.EVENT_FLOOR_REGISTRY_UPDATED,
288  self._async_clear_slot_list_async_clear_slot_list,
289  ),
290  self.hasshasshass.bus.async_listen(
291  er.EVENT_ENTITY_REGISTRY_UPDATED,
292  self._async_clear_slot_list_async_clear_slot_list,
293  event_filter=self._filter_entity_registry_changes_filter_entity_registry_changes,
294  ),
295  self.hasshasshass.bus.async_listen(
296  EVENT_STATE_CHANGED,
297  self._async_clear_slot_list_async_clear_slot_list,
298  event_filter=self._filter_state_changes_filter_state_changes,
299  ),
300  async_listen_entity_updates(self.hasshasshass, DOMAIN, self._async_clear_slot_list_async_clear_slot_list),
301  ]
302 
304  self, user_input: ConversationInput, strict_intents_only: bool = False
305  ) -> RecognizeResult | None:
306  """Recognize intent from user input."""
307  language = user_input.language or self.hasshasshass.config.language
308  lang_intents = await self.async_get_or_load_intentsasync_get_or_load_intents(language)
309 
310  if lang_intents is None:
311  # No intents loaded
312  _LOGGER.warning("No intents were loaded for language: %s", language)
313  return None
314 
315  slot_lists = self._make_slot_lists_make_slot_lists()
316  intent_context = self._make_intent_context_make_intent_context(user_input)
317 
318  if self._exposed_names_trie_exposed_names_trie is not None:
319  # Filter by input string
320  text_lower = user_input.text.strip().lower()
321  slot_lists["name"] = TextSlotList(
322  name="name",
323  values=[
324  result[2] for result in self._exposed_names_trie_exposed_names_trie.find(text_lower)
325  ],
326  )
327 
328  start = time.monotonic()
329 
330  result = await self.hasshasshass.async_add_executor_job(
331  self._recognize_recognize,
332  user_input,
333  lang_intents,
334  slot_lists,
335  intent_context,
336  language,
337  strict_intents_only,
338  )
339 
340  _LOGGER.debug(
341  "Recognize done in %.2f seconds",
342  time.monotonic() - start,
343  )
344 
345  return result
346 
347  async def async_process(self, user_input: ConversationInput) -> ConversationResult:
348  """Process a sentence."""
349 
350  # Check if a trigger matched
351  if trigger_result := await self.async_recognize_sentence_triggerasync_recognize_sentence_trigger(user_input):
352  # Process callbacks and get response
353  response_text = await self._handle_trigger_result_handle_trigger_result(
354  trigger_result, user_input
355  )
356 
357  # Convert to conversation result
358  response = intent.IntentResponse(
359  language=user_input.language or self.hasshasshass.config.language
360  )
361  response.response_type = intent.IntentResponseType.ACTION_DONE
362  response.async_set_speech(response_text)
363 
364  return ConversationResult(response=response)
365 
366  # Match intents
367  intent_result = await self.async_recognize_intentasync_recognize_intent(user_input)
368  return await self._async_process_intent_result_async_process_intent_result(intent_result, user_input)
369 
371  self,
372  result: RecognizeResult | None,
373  user_input: ConversationInput,
374  ) -> ConversationResult:
375  """Process user input with intents."""
376  language = user_input.language or self.hasshasshass.config.language
377  conversation_id = None # Not supported
378 
379  # Intent match or failure
380  lang_intents = await self.async_get_or_load_intentsasync_get_or_load_intents(language)
381 
382  if result is None:
383  # Intent was not recognized
384  _LOGGER.debug("No intent was matched for '%s'", user_input.text)
385  return _make_error_result(
386  language,
387  intent.IntentResponseErrorCode.NO_INTENT_MATCH,
388  self._get_error_text_get_error_text(ErrorKey.NO_INTENT, lang_intents),
389  conversation_id,
390  )
391 
392  if result.unmatched_entities:
393  # Intent was recognized, but not entity/area names, etc.
394  _LOGGER.debug(
395  "Recognized intent '%s' for template '%s' but had unmatched: %s",
396  result.intent.name,
397  (
398  result.intent_sentence.text
399  if result.intent_sentence is not None
400  else ""
401  ),
402  result.unmatched_entities_list,
403  )
404  error_response_type, error_response_args = _get_unmatched_response(result)
405  return _make_error_result(
406  language,
407  intent.IntentResponseErrorCode.NO_VALID_TARGETS,
408  self._get_error_text_get_error_text(
409  error_response_type, lang_intents, **error_response_args
410  ),
411  conversation_id,
412  )
413 
414  # Will never happen because result will be None when no intents are
415  # loaded in async_recognize.
416  assert lang_intents is not None
417 
418  # Slot values to pass to the intent
419  slots: dict[str, Any] = {
420  entity.name: {
421  "value": entity.value,
422  "text": entity.text or entity.value,
423  }
424  for entity in result.entities_list
425  }
426  device_area = self._get_device_area_get_device_area(user_input.device_id)
427  if device_area:
428  slots["preferred_area_id"] = {"value": device_area.id}
430  ConversationTraceEventType.TOOL_CALL,
431  {
432  "intent_name": result.intent.name,
433  "slots": {
434  entity.name: entity.value or entity.text
435  for entity in result.entities_list
436  },
437  },
438  )
439 
440  try:
441  intent_response = await intent.async_handle(
442  self.hasshasshass,
443  DOMAIN,
444  result.intent.name,
445  slots,
446  user_input.text,
447  user_input.context,
448  language,
449  assistant=DOMAIN,
450  device_id=user_input.device_id,
451  conversation_agent_id=user_input.agent_id,
452  )
453  except intent.MatchFailedError as match_error:
454  # Intent was valid, but no entities matched the constraints.
455  error_response_type, error_response_args = _get_match_error_response(
456  self.hasshasshass, match_error
457  )
458  return _make_error_result(
459  language,
460  intent.IntentResponseErrorCode.NO_VALID_TARGETS,
461  self._get_error_text_get_error_text(
462  error_response_type, lang_intents, **error_response_args
463  ),
464  conversation_id,
465  )
466  except intent.IntentHandleError as err:
467  # Intent was valid and entities matched constraints, but an error
468  # occurred during handling.
469  _LOGGER.exception("Intent handling error")
470  return _make_error_result(
471  language,
472  intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
473  self._get_error_text_get_error_text(
474  err.response_key or ErrorKey.HANDLE_ERROR, lang_intents
475  ),
476  conversation_id,
477  )
478  except intent.IntentUnexpectedError:
479  _LOGGER.exception("Unexpected intent error")
480  return _make_error_result(
481  language,
482  intent.IntentResponseErrorCode.UNKNOWN,
483  self._get_error_text_get_error_text(ErrorKey.HANDLE_ERROR, lang_intents),
484  conversation_id,
485  )
486 
487  if (
488  (not intent_response.speech)
489  and (intent_response.intent is not None)
490  and (response_key := result.response)
491  ):
492  # Use response template, if available
493  response_template_str = lang_intents.intent_responses.get(
494  result.intent.name, {}
495  ).get(response_key)
496  if response_template_str:
497  response_template = template.Template(response_template_str, self.hasshasshass)
498  speech = await self._build_speech_build_speech(
499  language, response_template, intent_response, result
500  )
501  intent_response.async_set_speech(speech)
502 
503  return ConversationResult(
504  response=intent_response, conversation_id=conversation_id
505  )
506 
508  self,
509  user_input: ConversationInput,
510  lang_intents: LanguageIntents,
511  slot_lists: dict[str, SlotList],
512  intent_context: dict[str, Any] | None,
513  language: str,
514  strict_intents_only: bool,
515  ) -> RecognizeResult | None:
516  """Search intents for a match to user input."""
517  skip_exposed_match = False
518 
519  # Try cache first
520  cache_key = IntentCacheKey(
521  text=user_input.text, language=language, device_id=user_input.device_id
522  )
523  cache_value = self._intent_cache_intent_cache.get(cache_key)
524  if cache_value is not None:
525  if (cache_value.result is not None) and (
526  cache_value.stage == IntentMatchingStage.EXPOSED_ENTITIES_ONLY
527  ):
528  _LOGGER.debug("Got cached result for exposed entities")
529  return cache_value.result
530 
531  # Continue with matching, but we know we won't succeed for exposed
532  # entities only.
533  skip_exposed_match = True
534 
535  if not skip_exposed_match:
536  start_time = time.monotonic()
537  strict_result = self._recognize_strict_recognize_strict(
538  user_input, lang_intents, slot_lists, intent_context, language
539  )
540  _LOGGER.debug(
541  "Checked exposed entities in %s second(s)",
542  time.monotonic() - start_time,
543  )
544 
545  # Update cache
546  self._intent_cache_intent_cache.put(
547  cache_key,
549  result=strict_result,
550  stage=IntentMatchingStage.EXPOSED_ENTITIES_ONLY,
551  ),
552  )
553 
554  if strict_result is not None:
555  # Successful strict match with exposed entities
556  return strict_result
557 
558  if strict_intents_only:
559  # Don't try matching against all entities or doing a fuzzy match
560  return None
561 
562  # Try again with all entities (including unexposed)
563  skip_unexposed_entities_match = False
564  if cache_value is not None:
565  if (cache_value.result is not None) and (
566  cache_value.stage == IntentMatchingStage.UNEXPOSED_ENTITIES
567  ):
568  _LOGGER.debug("Got cached result for all entities")
569  return cache_value.result
570 
571  # Continue with matching, but we know we won't succeed for all
572  # entities.
573  skip_unexposed_entities_match = True
574 
575  if not skip_unexposed_entities_match:
576  unexposed_entities_slot_lists = {
577  **slot_lists,
578  "name": self._get_unexposed_entity_names_get_unexposed_entity_names(user_input.text),
579  }
580 
581  start_time = time.monotonic()
582  strict_result = self._recognize_strict_recognize_strict(
583  user_input,
584  lang_intents,
585  unexposed_entities_slot_lists,
586  intent_context,
587  language,
588  )
589 
590  _LOGGER.debug(
591  "Checked all entities in %s second(s)", time.monotonic() - start_time
592  )
593 
594  # Update cache
595  self._intent_cache_intent_cache.put(
596  cache_key,
598  result=strict_result, stage=IntentMatchingStage.UNEXPOSED_ENTITIES
599  ),
600  )
601 
602  if strict_result is not None:
603  # Not a successful match, but useful for an error message.
604  # This should fail the intent handling phase (async_match_targets).
605  return strict_result
606 
607  # Try again with missing entities enabled
608  skip_fuzzy_match = False
609  if cache_value is not None:
610  if (cache_value.result is not None) and (
611  cache_value.stage == IntentMatchingStage.FUZZY
612  ):
613  _LOGGER.debug("Got cached result for fuzzy match")
614  return cache_value.result
615 
616  # We know we won't succeed for fuzzy matching.
617  skip_fuzzy_match = True
618 
619  maybe_result: RecognizeResult | None = None
620  if not skip_fuzzy_match:
621  start_time = time.monotonic()
622  best_num_matched_entities = 0
623  best_num_unmatched_entities = 0
624  best_num_unmatched_ranges = 0
625  for result in recognize_all(
626  user_input.text,
627  lang_intents.intents,
628  slot_lists=slot_lists,
629  intent_context=intent_context,
630  allow_unmatched_entities=True,
631  ):
632  if result.text_chunks_matched < 1:
633  # Skip results that don't match any literal text
634  continue
635 
636  # Don't count missing entities that couldn't be filled from context
637  num_matched_entities = 0
638  for matched_entity in result.entities_list:
639  if matched_entity.name not in result.unmatched_entities:
640  num_matched_entities += 1
641 
642  num_unmatched_entities = 0
643  num_unmatched_ranges = 0
644  for unmatched_entity in result.unmatched_entities_list:
645  if isinstance(unmatched_entity, UnmatchedTextEntity):
646  if unmatched_entity.text != MISSING_ENTITY:
647  num_unmatched_entities += 1
648  elif isinstance(unmatched_entity, UnmatchedRangeEntity):
649  num_unmatched_ranges += 1
650  num_unmatched_entities += 1
651  else:
652  num_unmatched_entities += 1
653 
654  if (
655  (maybe_result is None) # first result
656  or (num_matched_entities > best_num_matched_entities)
657  or (
658  # Fewer unmatched entities
659  (num_matched_entities == best_num_matched_entities)
660  and (num_unmatched_entities < best_num_unmatched_entities)
661  )
662  or (
663  # Prefer unmatched ranges
664  (num_matched_entities == best_num_matched_entities)
665  and (num_unmatched_entities == best_num_unmatched_entities)
666  and (num_unmatched_ranges > best_num_unmatched_ranges)
667  )
668  or (
669  # More literal text matched
670  (num_matched_entities == best_num_matched_entities)
671  and (num_unmatched_entities == best_num_unmatched_entities)
672  and (num_unmatched_ranges == best_num_unmatched_ranges)
673  and (
674  result.text_chunks_matched
675  > maybe_result.text_chunks_matched
676  )
677  )
678  or (
679  # Prefer match failures with entities
680  (result.text_chunks_matched == maybe_result.text_chunks_matched)
681  and (num_unmatched_entities == best_num_unmatched_entities)
682  and (num_unmatched_ranges == best_num_unmatched_ranges)
683  and (
684  ("name" in result.entities)
685  or ("name" in result.unmatched_entities)
686  )
687  )
688  ):
689  maybe_result = result
690  best_num_matched_entities = num_matched_entities
691  best_num_unmatched_entities = num_unmatched_entities
692  best_num_unmatched_ranges = num_unmatched_ranges
693 
694  # Update cache
695  self._intent_cache_intent_cache.put(
696  cache_key,
697  IntentCacheValue(result=maybe_result, stage=IntentMatchingStage.FUZZY),
698  )
699 
700  _LOGGER.debug(
701  "Did fuzzy match in %s second(s)", time.monotonic() - start_time
702  )
703 
704  return maybe_result
705 
706  def _get_unexposed_entity_names(self, text: str) -> TextSlotList:
707  """Get filtered slot list with unexposed entity names in Home Assistant."""
708  if self._unexposed_names_trie_unexposed_names_trie is None:
709  # Build trie
710  self._unexposed_names_trie_unexposed_names_trie = Trie()
711  for name_tuple in self._get_entity_name_tuples_get_entity_name_tuples(exposed=False):
712  self._unexposed_names_trie_unexposed_names_trie.insert(
713  name_tuple[0].lower(),
714  TextSlotValue.from_tuple(name_tuple, allow_template=False),
715  )
716 
717  # Build filtered slot list
718  text_lower = text.strip().lower()
719  return TextSlotList(
720  name="name",
721  values=[
722  result[2] for result in self._unexposed_names_trie_unexposed_names_trie.find(text_lower)
723  ],
724  )
725 
727  self, exposed: bool
728  ) -> Iterable[tuple[str, str, dict[str, Any]]]:
729  """Yield (input name, output name, context) tuples for entities."""
730  entity_registry = er.async_get(self.hasshasshass)
731 
732  for state in self.hasshasshass.states.async_all():
733  entity_exposed = async_should_expose(self.hasshasshass, DOMAIN, state.entity_id)
734  if exposed and (not entity_exposed):
735  # Required exposed, entity is not
736  continue
737 
738  if (not exposed) and entity_exposed:
739  # Required not exposed, entity is
740  continue
741 
742  # Checked against "requires_context" and "excludes_context" in hassil
743  context = {"domain": state.domain}
744  if state.attributes:
745  # Include some attributes
746  for attr in DEFAULT_EXPOSED_ATTRIBUTES:
747  if attr not in state.attributes:
748  continue
749  context[attr] = state.attributes[attr]
750 
751  if (
752  entity := entity_registry.async_get(state.entity_id)
753  ) and entity.aliases:
754  for alias in entity.aliases:
755  alias = alias.strip()
756  if not alias:
757  continue
758 
759  yield (alias, alias, context)
760 
761  # Default name
762  yield (state.name, state.name, context)
763 
765  self,
766  user_input: ConversationInput,
767  lang_intents: LanguageIntents,
768  slot_lists: dict[str, SlotList],
769  intent_context: dict[str, Any] | None,
770  language: str,
771  ) -> RecognizeResult | None:
772  """Search intents for a strict match to user input."""
773  return recognize_best(
774  user_input.text,
775  lang_intents.intents,
776  slot_lists=slot_lists,
777  intent_context=intent_context,
778  language=language,
779  best_metadata_key=METADATA_CUSTOM_SENTENCE,
780  best_slot_name="name",
781  )
782 
783  async def _build_speech(
784  self,
785  language: str,
786  response_template: template.Template,
787  intent_response: intent.IntentResponse,
788  recognize_result: RecognizeResult,
789  ) -> str:
790  # Make copies of the states here so we can add translated names for responses.
791  matched = [
792  state_copy
793  for state in intent_response.matched_states
794  if (state_copy := core.State.from_dict(state.as_dict()))
795  ]
796  unmatched = [
797  state_copy
798  for state in intent_response.unmatched_states
799  if (state_copy := core.State.from_dict(state.as_dict()))
800  ]
801  all_states = matched + unmatched
802  domains = {state.domain for state in all_states}
803  translations = await translation.async_get_translations(
804  self.hasshasshass, language, "entity_component", domains
805  )
806 
807  # Use translated state names
808  for state in all_states:
809  device_class = state.attributes.get("device_class", "_")
810  key = f"component.{state.domain}.entity_component.{device_class}.state.{state.state}"
811  state.state = translations.get(key, state.state)
812 
813  # Get first matched or unmatched state.
814  # This is available in the response template as "state".
815  state1: core.State | None = None
816  if intent_response.matched_states:
817  state1 = matched[0]
818  elif intent_response.unmatched_states:
819  state1 = unmatched[0]
820 
821  # Render response template
822  speech_slots = {
823  entity_name: entity_value.text or entity_value.value
824  for entity_name, entity_value in recognize_result.entities.items()
825  }
826  speech_slots.update(intent_response.speech_slots)
827 
828  speech = response_template.async_render(
829  {
830  # Slots from intent recognizer and response
831  "slots": speech_slots,
832  # First matched or unmatched state
833  "state": (
834  template.TemplateState(self.hasshasshass, state1)
835  if state1 is not None
836  else None
837  ),
838  "query": {
839  # Entity states that matched the query (e.g, "on")
840  "matched": [
841  template.TemplateState(self.hasshasshass, state) for state in matched
842  ],
843  # Entity states that did not match the query
844  "unmatched": [
845  template.TemplateState(self.hasshasshass, state) for state in unmatched
846  ],
847  },
848  }
849  )
850 
851  # Normalize whitespace
852  if speech is not None:
853  speech = str(speech)
854  speech = " ".join(speech.strip().split())
855 
856  return speech
857 
858  async def async_reload(self, language: str | None = None) -> None:
859  """Clear cached intents for a language."""
860  if language is None:
861  self._lang_intents.clear()
862  _LOGGER.debug("Cleared intents for all languages")
863  else:
864  self._lang_intents.pop(language, None)
865  _LOGGER.debug("Cleared intents for language: %s", language)
866 
867  # Intents have changed, so we must clear the cache
868  self._intent_cache_intent_cache.clear()
869 
870  async def async_prepare(self, language: str | None = None) -> None:
871  """Load intents for a language."""
872  if language is None:
873  language = self.hasshasshass.config.language
874 
875  lang_intents = await self.async_get_or_load_intentsasync_get_or_load_intents(language)
876 
877  # No intents loaded
878  if lang_intents is None:
879  return
880 
881  self._make_slot_lists_make_slot_lists()
882 
883  async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None:
884  """Load all intents of a language with lock."""
885  if lang_intents := self._lang_intents.get(language):
886  return (
887  None
888  if lang_intents is ERROR_SENTINEL
889  else cast(LanguageIntents, lang_intents)
890  )
891 
892  async with self._load_intents_lock_load_intents_lock:
893  # In case it was loaded now
894  if lang_intents := self._lang_intents.get(language):
895  return (
896  None
897  if lang_intents is ERROR_SENTINEL
898  else cast(LanguageIntents, lang_intents)
899  )
900 
901  start = time.monotonic()
902 
903  result = await self.hasshasshass.async_add_executor_job(
904  self._load_intents_load_intents, language
905  )
906 
907  if result is None:
908  self._lang_intents[language] = ERROR_SENTINEL
909  else:
910  self._lang_intents[language] = result
911 
912  _LOGGER.debug(
913  "Full intents load completed for language=%s in %.2f seconds",
914  language,
915  time.monotonic() - start,
916  )
917 
918  return result
919 
920  def _load_intents(self, language: str) -> LanguageIntents | None:
921  """Load all intents for language (run inside executor)."""
922  intents_dict: dict[str, Any] = {}
923  language_variant: str | None = None
924  supported_langs = set(get_languages())
925 
926  # Choose a language variant upfront and commit to it for custom
927  # sentences, etc.
928  all_language_variants = {lang.lower(): lang for lang in supported_langs}
929 
930  # en-US, en_US, en, ...
931  for maybe_variant in _get_language_variations(language):
932  matching_variant = all_language_variants.get(maybe_variant.lower())
933  if matching_variant:
934  language_variant = matching_variant
935  break
936 
937  if not language_variant:
938  _LOGGER.warning(
939  "Unable to find supported language variant for %s", language
940  )
941  return None
942 
943  # Load intents for this language variant
944  lang_variant_intents = get_intents(language_variant, json_load=json_load)
945 
946  if lang_variant_intents:
947  # Merge sentences into existing dictionary
948  # Overriding because source dict is empty
949  intents_dict = lang_variant_intents
950 
951  _LOGGER.debug(
952  "Loaded built-in intents for language=%s (%s)",
953  language,
954  language_variant,
955  )
956 
957  # Check for custom sentences in <config>/custom_sentences/<language>/
958  custom_sentences_dir = Path(
959  self.hasshasshass.config.path("custom_sentences", language_variant)
960  )
961  if custom_sentences_dir.is_dir():
962  for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"):
963  with custom_sentences_path.open(
964  encoding="utf-8"
965  ) as custom_sentences_file:
966  # Merge custom sentences
967  if not isinstance(
968  custom_sentences_yaml := yaml.safe_load(custom_sentences_file),
969  dict,
970  ):
971  _LOGGER.warning(
972  "Custom sentences file does not match expected format path=%s",
973  custom_sentences_file.name,
974  )
975  continue
976 
977  # Add metadata so we can identify custom sentences in the debugger
978  custom_intents_dict = custom_sentences_yaml.get("intents", {})
979  for intent_dict in custom_intents_dict.values():
980  intent_data_list = intent_dict.get("data", [])
981  for intent_data in intent_data_list:
982  sentence_metadata = intent_data.get("metadata", {})
983  sentence_metadata[METADATA_CUSTOM_SENTENCE] = True
984  sentence_metadata[METADATA_CUSTOM_FILE] = str(
985  custom_sentences_path.relative_to(
986  custom_sentences_dir.parent
987  )
988  )
989  intent_data["metadata"] = sentence_metadata
990 
991  merge_dict(intents_dict, custom_sentences_yaml)
992 
993  _LOGGER.debug(
994  "Loaded custom sentences language=%s (%s), path=%s",
995  language,
996  language_variant,
997  custom_sentences_path,
998  )
999 
1000  # Load sentences from HA config for default language only
1001  if self._config_intents and (
1002  self.hasshasshass.config.language in (language, language_variant)
1003  ):
1004  hass_config_path = self.hasshasshass.config.path()
1005  merge_dict(
1006  intents_dict,
1007  {
1008  "intents": {
1009  intent_name: {
1010  "data": [
1011  {
1012  "sentences": sentences,
1013  "metadata": {
1014  METADATA_CUSTOM_SENTENCE: True,
1015  METADATA_CUSTOM_FILE: hass_config_path,
1016  },
1017  }
1018  ]
1019  }
1020  for intent_name, sentences in self._config_intents.items()
1021  }
1022  },
1023  )
1024  _LOGGER.debug(
1025  "Loaded intents from configuration.yaml",
1026  )
1027 
1028  if not intents_dict:
1029  return None
1030 
1031  intents = Intents.from_dict(intents_dict)
1032 
1033  # Load responses
1034  responses_dict = intents_dict.get("responses", {})
1035  intent_responses = responses_dict.get("intents", {})
1036  error_responses = responses_dict.get("errors", {})
1037 
1038  return LanguageIntents(
1039  intents,
1040  intents_dict,
1041  intent_responses,
1042  error_responses,
1043  language_variant,
1044  )
1045 
1046  @core.callback
1047  def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None:
1048  """Clear slot lists when a registry has changed."""
1049  # Two subscribers can be scheduled at same time
1050  _LOGGER.debug("Clearing slot lists")
1051  if self._unsub_clear_slot_list_unsub_clear_slot_list is None:
1052  return
1053  self._slot_lists_slot_lists = None
1054  self._exposed_names_trie_exposed_names_trie = None
1055  self._unexposed_names_trie_unexposed_names_trie = None
1056  for unsub in self._unsub_clear_slot_list_unsub_clear_slot_list:
1057  unsub()
1058  self._unsub_clear_slot_list_unsub_clear_slot_list = None
1059 
1060  # Slot lists have changed, so we must clear the cache
1061  self._intent_cache_intent_cache.clear()
1062 
1063  @core.callback
1064  def _make_slot_lists(self) -> dict[str, SlotList]:
1065  """Create slot lists with areas and entity names/aliases."""
1066  if self._slot_lists_slot_lists is not None:
1067  return self._slot_lists_slot_lists
1068 
1069  start = time.monotonic()
1070 
1071  # Gather entity names, keeping track of exposed names.
1072  # We try intent recognition with only exposed names first, then all names.
1073  #
1074  # NOTE: We do not pass entity ids in here because multiple entities may
1075  # have the same name. The intent matcher doesn't gather all matching
1076  # values for a list, just the first. So we will need to match by name no
1077  # matter what.
1078  exposed_entity_names = list(self._get_entity_name_tuples_get_entity_name_tuples(exposed=True))
1079  _LOGGER.debug("Exposed entities: %s", exposed_entity_names)
1080 
1081  # Expose all areas.
1082  areas = ar.async_get(self.hasshasshass)
1083  area_names = []
1084  for area in areas.async_list_areas():
1085  area_names.append((area.name, area.name))
1086  if not area.aliases:
1087  continue
1088 
1089  for alias in area.aliases:
1090  alias = alias.strip()
1091  if not alias:
1092  continue
1093 
1094  area_names.append((alias, alias))
1095 
1096  # Expose all floors.
1097  floors = fr.async_get(self.hasshasshass)
1098  floor_names = []
1099  for floor in floors.async_list_floors():
1100  floor_names.append((floor.name, floor.name))
1101  if not floor.aliases:
1102  continue
1103 
1104  for alias in floor.aliases:
1105  alias = alias.strip()
1106  if not alias:
1107  continue
1108 
1109  floor_names.append((alias, floor.name))
1110 
1111  # Build trie
1112  self._exposed_names_trie_exposed_names_trie = Trie()
1113  name_list = TextSlotList.from_tuples(exposed_entity_names, allow_template=False)
1114  for name_value in name_list.values:
1115  assert isinstance(name_value.text_in, TextChunk)
1116  name_text = name_value.text_in.text.strip().lower()
1117  self._exposed_names_trie_exposed_names_trie.insert(name_text, name_value)
1118 
1119  self._slot_lists_slot_lists = {
1120  "area": TextSlotList.from_tuples(area_names, allow_template=False),
1121  "name": name_list,
1122  "floor": TextSlotList.from_tuples(floor_names, allow_template=False),
1123  }
1124 
1125  self._listen_clear_slot_list_listen_clear_slot_list()
1126 
1127  _LOGGER.debug(
1128  "Created slot lists in %.2f seconds",
1129  time.monotonic() - start,
1130  )
1131 
1132  return self._slot_lists_slot_lists
1133 
1135  self, user_input: ConversationInput
1136  ) -> dict[str, Any] | None:
1137  """Return intent recognition context for user input."""
1138  if not user_input.device_id:
1139  return None
1140 
1141  device_area = self._get_device_area_get_device_area(user_input.device_id)
1142  if device_area is None:
1143  return None
1144 
1145  return {"area": {"value": device_area.name, "text": device_area.name}}
1146 
1147  def _get_device_area(self, device_id: str | None) -> ar.AreaEntry | None:
1148  """Return area object for given device identifier."""
1149  if device_id is None:
1150  return None
1151 
1152  devices = dr.async_get(self.hasshasshass)
1153  device = devices.async_get(device_id)
1154  if (device is None) or (device.area_id is None):
1155  return None
1156 
1157  areas = ar.async_get(self.hasshasshass)
1158 
1159  return areas.async_get_area(device.area_id)
1160 
1162  self,
1163  error_key: ErrorKey | str,
1164  lang_intents: LanguageIntents | None,
1165  **response_args,
1166  ) -> str:
1167  """Get response error text by type."""
1168  if lang_intents is None:
1169  return _DEFAULT_ERROR_TEXT
1170 
1171  if isinstance(error_key, ErrorKey):
1172  response_key = error_key.value
1173  else:
1174  response_key = error_key
1175 
1176  response_str = (
1177  lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT
1178  )
1179  response_template = template.Template(response_str, self.hasshasshass)
1180 
1181  return response_template.async_render(response_args)
1182 
1183  @core.callback
1185  self,
1186  sentences: list[str],
1187  callback: TRIGGER_CALLBACK_TYPE,
1188  ) -> core.CALLBACK_TYPE:
1189  """Register a list of sentences that will trigger a callback when recognized."""
1190  trigger_data = TriggerData(sentences=sentences, callback=callback)
1191  self._trigger_sentences.append(trigger_data)
1192 
1193  # Force rebuild on next use
1194  self._trigger_intents_trigger_intents = None
1195 
1196  return functools.partial(self._unregister_trigger_unregister_trigger, trigger_data)
1197 
1198  @core.callback
1199  def _rebuild_trigger_intents(self) -> None:
1200  """Rebuild the HassIL intents object from the current trigger sentences."""
1201  intents_dict = {
1202  "language": self.hasshasshass.config.language,
1203  "intents": {
1204  # Use trigger data index as a virtual intent name for HassIL.
1205  # This works because the intents are rebuilt on every
1206  # register/unregister.
1207  str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]}
1208  for trigger_id, trigger_data in enumerate(self._trigger_sentences)
1209  },
1210  }
1211 
1212  trigger_intents = Intents.from_dict(intents_dict)
1213 
1214  # Assume slot list references are wildcards
1215  wildcard_names: set[str] = set()
1216  for trigger_intent in trigger_intents.intents.values():
1217  for intent_data in trigger_intent.data:
1218  for sentence in intent_data.sentences:
1219  _collect_list_references(sentence, wildcard_names)
1220 
1221  for wildcard_name in wildcard_names:
1222  trigger_intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
1223 
1224  self._trigger_intents_trigger_intents = trigger_intents
1225 
1226  _LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
1227 
1228  @core.callback
1229  def _unregister_trigger(self, trigger_data: TriggerData) -> None:
1230  """Unregister a set of trigger sentences."""
1231  self._trigger_sentences.remove(trigger_data)
1232 
1233  # Force rebuild on next use
1234  self._trigger_intents_trigger_intents = None
1235 
1237  self, user_input: ConversationInput
1238  ) -> SentenceTriggerResult | None:
1239  """Try to match sentence against registered trigger sentences.
1240 
1241  Calls the registered callbacks if there's a match and returns a sentence
1242  trigger result.
1243  """
1244  if not self._trigger_sentences:
1245  # No triggers registered
1246  return None
1247 
1248  if self._trigger_intents_trigger_intents is None:
1249  # Need to rebuild intents before matching
1250  self._rebuild_trigger_intents_rebuild_trigger_intents()
1251 
1252  assert self._trigger_intents_trigger_intents is not None
1253 
1254  matched_triggers: dict[int, RecognizeResult] = {}
1255  matched_template: str | None = None
1256  for result in recognize_all(user_input.text, self._trigger_intents_trigger_intents):
1257  if result.intent_sentence is not None:
1258  matched_template = result.intent_sentence.text
1259 
1260  trigger_id = int(result.intent.name)
1261  if trigger_id in matched_triggers:
1262  # Already matched a sentence from this trigger
1263  break
1264 
1265  matched_triggers[trigger_id] = result
1266 
1267  if not matched_triggers:
1268  # Sentence did not match any trigger sentences
1269  return None
1270 
1271  _LOGGER.debug(
1272  "'%s' matched %s trigger(s): %s",
1273  user_input.text,
1274  len(matched_triggers),
1275  list(matched_triggers),
1276  )
1277 
1278  return SentenceTriggerResult(
1279  user_input.text, matched_template, matched_triggers
1280  )
1281 
1283  self, result: SentenceTriggerResult, user_input: ConversationInput
1284  ) -> str:
1285  """Run sentence trigger callbacks and return response text."""
1286 
1287  # Gather callback responses in parallel
1288  trigger_callbacks = [
1289  self._trigger_sentences[trigger_id].callback(
1290  user_input.text, trigger_result, user_input.device_id
1291  )
1292  for trigger_id, trigger_result in result.matched_triggers.items()
1293  ]
1294 
1295  # Use first non-empty result as response.
1296  #
1297  # There may be multiple copies of a trigger running when editing in
1298  # the UI, so it's critical that we filter out empty responses here.
1299  response_text = ""
1300  response_set_by_trigger = False
1301  for trigger_future in asyncio.as_completed(trigger_callbacks):
1302  trigger_response = await trigger_future
1303  if trigger_response is None:
1304  continue
1305 
1306  response_text = trigger_response
1307  response_set_by_trigger = True
1308  break
1309 
1310  if response_set_by_trigger:
1311  # Response was explicitly set to empty
1312  response_text = response_text or ""
1313  elif not response_text:
1314  # Use translated acknowledgment for pipeline language
1315  language = user_input.language or self.hasshasshass.config.language
1316  translations = await translation.async_get_translations(
1317  self.hasshasshass, language, DOMAIN, [DOMAIN]
1318  )
1319  response_text = translations.get(
1320  f"component.{DOMAIN}.conversation.agent.done", "Done"
1321  )
1322 
1323  return response_text
1324 
1326  self, user_input: ConversationInput
1327  ) -> str | None:
1328  """Try to input sentence against sentence triggers and return response text.
1329 
1330  Returns None if no match occurred.
1331  """
1332  if trigger_result := await self.async_recognize_sentence_triggerasync_recognize_sentence_trigger(user_input):
1333  return await self._handle_trigger_result_handle_trigger_result(trigger_result, user_input)
1334 
1335  return None
1336 
1338  self,
1339  user_input: ConversationInput,
1340  ) -> intent.IntentResponse | None:
1341  """Try to match sentence against registered intents and return response.
1342 
1343  Only performs strict matching with exposed entities and exact wording.
1344  Returns None if no match occurred.
1345  """
1346  result = await self.async_recognize_intentasync_recognize_intent(user_input, strict_intents_only=True)
1347  if not isinstance(result, RecognizeResult):
1348  # No error message on failed match
1349  return None
1350 
1351  conversation_result = await self._async_process_intent_result_async_process_intent_result(
1352  result, user_input
1353  )
1354  return conversation_result.response
1355 
1356 
1358  language: str,
1359  error_code: intent.IntentResponseErrorCode,
1360  response_text: str,
1361  conversation_id: str | None = None,
1362 ) -> ConversationResult:
1363  """Create conversation result with error code and text."""
1364  response = intent.IntentResponse(language=language)
1365  response.async_set_error(error_code, response_text)
1366  return ConversationResult(response, conversation_id)
1367 
1368 
1369 def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str, Any]]:
1370  """Get key and template arguments for error when there are unmatched intent entities/slots."""
1371 
1372  # Filter out non-text and missing context entities
1373  unmatched_text: dict[str, str] = {
1374  key: entity.text.strip()
1375  for key, entity in result.unmatched_entities.items()
1376  if isinstance(entity, UnmatchedTextEntity) and entity.text != MISSING_ENTITY
1377  }
1378 
1379  if unmatched_area := unmatched_text.get("area"):
1380  # area only
1381  return ErrorKey.NO_AREA, {"area": unmatched_area}
1382 
1383  if unmatched_floor := unmatched_text.get("floor"):
1384  # floor only
1385  return ErrorKey.NO_FLOOR, {"floor": unmatched_floor}
1386 
1387  # Area may still have matched
1388  matched_area: str | None = None
1389  if matched_area_entity := result.entities.get("area"):
1390  matched_area = matched_area_entity.text.strip()
1391 
1392  matched_floor: str | None = None
1393  if matched_floor_entity := result.entities.get("floor"):
1394  matched_floor = matched_floor_entity.text.strip()
1395 
1396  if unmatched_name := unmatched_text.get("name"):
1397  if matched_area:
1398  # device in area
1399  return ErrorKey.NO_ENTITY_IN_AREA, {
1400  "entity": unmatched_name,
1401  "area": matched_area,
1402  }
1403  if matched_floor:
1404  # device on floor
1405  return ErrorKey.NO_ENTITY_IN_FLOOR, {
1406  "entity": unmatched_name,
1407  "floor": matched_floor,
1408  }
1409 
1410  # device only
1411  return ErrorKey.NO_ENTITY, {"entity": unmatched_name}
1412 
1413  # Default error
1414  return ErrorKey.NO_INTENT, {}
1415 
1416 
1418  hass: core.HomeAssistant,
1419  match_error: intent.MatchFailedError,
1420 ) -> tuple[ErrorKey, dict[str, Any]]:
1421  """Return key and template arguments for error when target matching fails."""
1422 
1423  constraints, result = match_error.constraints, match_error.result
1424  reason = result.no_match_reason
1425 
1426  if (
1427  reason
1428  in (intent.MatchFailedReason.DEVICE_CLASS, intent.MatchFailedReason.DOMAIN)
1429  ) and constraints.device_classes:
1430  device_class = next(iter(constraints.device_classes)) # first device class
1431  if constraints.area_name:
1432  # device_class in area
1433  return ErrorKey.NO_DEVICE_CLASS_IN_AREA, {
1434  "device_class": device_class,
1435  "area": constraints.area_name,
1436  }
1437 
1438  # device_class only
1439  return ErrorKey.NO_DEVICE_CLASS, {"device_class": device_class}
1440 
1441  if (reason == intent.MatchFailedReason.DOMAIN) and constraints.domains:
1442  domain = next(iter(constraints.domains)) # first domain
1443  if constraints.area_name:
1444  # domain in area
1445  return ErrorKey.NO_DOMAIN_IN_AREA, {
1446  "domain": domain,
1447  "area": constraints.area_name,
1448  }
1449 
1450  if constraints.floor_name:
1451  # domain in floor
1452  return ErrorKey.NO_DOMAIN_IN_FLOOR, {
1453  "domain": domain,
1454  "floor": constraints.floor_name,
1455  }
1456 
1457  # domain only
1458  return ErrorKey.NO_DOMAIN, {"domain": domain}
1459 
1460  if reason == intent.MatchFailedReason.DUPLICATE_NAME:
1461  if constraints.floor_name:
1462  # duplicate on floor
1463  return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, {
1464  "entity": result.no_match_name,
1465  "floor": constraints.floor_name,
1466  }
1467 
1468  if constraints.area_name:
1469  # duplicate on area
1470  return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, {
1471  "entity": result.no_match_name,
1472  "area": constraints.area_name,
1473  }
1474 
1475  return ErrorKey.DUPLICATE_ENTITIES, {"entity": result.no_match_name}
1476 
1477  if reason == intent.MatchFailedReason.INVALID_AREA:
1478  # Invalid area name
1479  return ErrorKey.NO_AREA, {"area": result.no_match_name}
1480 
1481  if reason == intent.MatchFailedReason.INVALID_FLOOR:
1482  # Invalid floor name
1483  return ErrorKey.NO_FLOOR, {"floor": result.no_match_name}
1484 
1485  if reason == intent.MatchFailedReason.FEATURE:
1486  # Feature not supported by entity
1487  return ErrorKey.FEATURE_NOT_SUPPORTED, {}
1488 
1489  if reason == intent.MatchFailedReason.STATE:
1490  # Entity is not in correct state
1491  assert constraints.states
1492  state = next(iter(constraints.states))
1493  if constraints.domains:
1494  # Translate if domain is available
1495  domain = next(iter(constraints.domains))
1496  state = translation.async_translate_state(
1497  hass, state, domain, None, None, None
1498  )
1499 
1500  return ErrorKey.ENTITY_WRONG_STATE, {"state": state}
1501 
1502  if reason == intent.MatchFailedReason.ASSISTANT:
1503  # Not exposed
1504  if constraints.name:
1505  if constraints.area_name:
1506  return ErrorKey.NO_ENTITY_IN_AREA_EXPOSED, {
1507  "entity": constraints.name,
1508  "area": constraints.area_name,
1509  }
1510  if constraints.floor_name:
1511  return ErrorKey.NO_ENTITY_IN_FLOOR_EXPOSED, {
1512  "entity": constraints.name,
1513  "floor": constraints.floor_name,
1514  }
1515  return ErrorKey.NO_ENTITY_EXPOSED, {"entity": constraints.name}
1516 
1517  if constraints.device_classes:
1518  device_class = next(iter(constraints.device_classes))
1519 
1520  if constraints.area_name:
1521  return ErrorKey.NO_DEVICE_CLASS_IN_AREA_EXPOSED, {
1522  "device_class": device_class,
1523  "area": constraints.area_name,
1524  }
1525  if constraints.floor_name:
1526  return ErrorKey.NO_DEVICE_CLASS_IN_FLOOR_EXPOSED, {
1527  "device_class": device_class,
1528  "floor": constraints.floor_name,
1529  }
1530  return ErrorKey.NO_DEVICE_CLASS_EXPOSED, {"device_class": device_class}
1531 
1532  if constraints.domains:
1533  domain = next(iter(constraints.domains))
1534 
1535  if constraints.area_name:
1536  return ErrorKey.NO_DOMAIN_IN_AREA_EXPOSED, {
1537  "domain": domain,
1538  "area": constraints.area_name,
1539  }
1540  if constraints.floor_name:
1541  return ErrorKey.NO_DOMAIN_IN_FLOOR_EXPOSED, {
1542  "domain": domain,
1543  "floor": constraints.floor_name,
1544  }
1545  return ErrorKey.NO_DOMAIN_EXPOSED, {"domain": domain}
1546 
1547  # Default error
1548  return ErrorKey.NO_INTENT, {}
1549 
1550 
1551 def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
1552  """Collect list reference names recursively."""
1553  if isinstance(expression, Sequence):
1554  seq: Sequence = expression
1555  for item in seq.items:
1556  _collect_list_references(item, list_names)
1557  elif isinstance(expression, ListReference):
1558  # {list}
1559  list_ref: ListReference = expression
1560  list_names.add(list_ref.slot_name)
RecognizeResult|None _recognize_strict(self, ConversationInput user_input, LanguageIntents lang_intents, dict[str, SlotList] slot_lists, dict[str, Any]|None intent_context, str language)
RecognizeResult|None async_recognize_intent(self, ConversationInput user_input, bool strict_intents_only=False)
LanguageIntents|None async_get_or_load_intents(self, str language)
SentenceTriggerResult|None async_recognize_sentence_trigger(self, ConversationInput user_input)
ConversationResult async_process(self, ConversationInput user_input)
LanguageIntents|None _load_intents(self, str language)
ar.AreaEntry|None _get_device_area(self, str|None device_id)
str _build_speech(self, str language, template.Template response_template, intent.IntentResponse intent_response, RecognizeResult recognize_result)
ConversationResult _async_process_intent_result(self, RecognizeResult|None result, ConversationInput user_input)
Iterable[tuple[str, str, dict[str, Any]]] _get_entity_name_tuples(self, bool exposed)
bool _filter_state_changes(self, core.EventStateChangedData event_data)
dict[str, Any]|None _make_intent_context(self, ConversationInput user_input)
None _async_clear_slot_list(self, core.Event[Any]|None event=None)
None __init__(self, core.HomeAssistant hass, dict[str, Any] config_intents)
bool _filter_entity_registry_changes(self, er.EventEntityRegistryUpdatedData event_data)
str _handle_trigger_result(self, SentenceTriggerResult result, ConversationInput user_input)
core.CALLBACK_TYPE register_trigger(self, list[str] sentences, TRIGGER_CALLBACK_TYPE callback)
str _get_error_text(self, ErrorKey|str error_key, LanguageIntents|None lang_intents, **response_args)
str|None async_handle_sentence_triggers(self, ConversationInput user_input)
RecognizeResult|None _recognize(self, ConversationInput user_input, LanguageIntents lang_intents, dict[str, SlotList] slot_lists, dict[str, Any]|None intent_context, str language, bool strict_intents_only)
intent.IntentResponse|None async_handle_intents(self, ConversationInput user_input)
IntentCacheValue|None get(self, IntentCacheKey key)
None put(self, IntentCacheKey key, IntentCacheValue value)
bool remove(self, _T matcher)
Definition: match.py:214
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
ConversationResult _make_error_result(str language, intent.IntentResponseErrorCode error_code, str response_text, str|None conversation_id=None)
None async_setup_default_agent(core.HomeAssistant hass, EntityComponent[ConversationEntity] entity_component, dict[str, Any] config_intents)
tuple[ErrorKey, dict[str, Any]] _get_match_error_response(core.HomeAssistant hass, intent.MatchFailedError match_error)
tuple[ErrorKey, dict[str, Any]] _get_unmatched_response(RecognizeResult result)
Iterable[str] _get_language_variations(str language)
None _collect_list_references(Expression expression, set[str] list_names)
None async_conversation_trace_append(ConversationTraceEventType event_type, dict[str, Any] event_data)
Definition: trace.py:88
CALLBACK_TYPE async_listen_entity_updates(HomeAssistant hass, str assistant, Callable[[], None] listener)
bool async_should_expose(HomeAssistant hass, str assistant, str entity_id)
CALLBACK_TYPE async_track_state_added_domain(HomeAssistant hass, str|Iterable[str] domains, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:648
JsonObjectType json_loads_object(bytes|bytearray|memoryview|str obj)
Definition: json.py:54