1 """Standard conversation implementation for Home Assistant."""
3 from __future__
import annotations
6 from collections
import OrderedDict
7 from collections.abc
import Awaitable, Callable, Iterable
8 from dataclasses
import dataclass
9 from enum
import Enum, auto
12 from pathlib
import Path
15 from typing
import IO, Any, cast
17 from hassil.expression
import Expression, ListReference, Sequence, TextChunk
18 from hassil.intents
import (
25 from hassil.recognize
import (
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
37 from homeassistant
import core
39 async_listen_entity_updates,
45 device_registry
as dr,
46 entity_registry
as er,
59 DEFAULT_EXPOSED_ATTRIBUTES,
61 ConversationEntityFeature,
63 from .entity
import ConversationEntity
64 from .models
import ConversationInput, ConversationResult
65 from .trace
import ConversationTraceEventType, async_conversation_trace_append
67 _LOGGER = logging.getLogger(__name__)
68 _DEFAULT_ERROR_TEXT =
"Sorry, I couldn't understand that"
69 _ENTITY_REGISTRY_UPDATE_FIELDS = [
"aliases",
"name",
"original_name"]
71 REGEX_TYPE = type(re.compile(
""))
72 TRIGGER_CALLBACK_TYPE = Callable[
73 [str, RecognizeResult, str |
None], Awaitable[str |
None]
75 METADATA_CUSTOM_SENTENCE =
"hass_custom_sentence"
76 METADATA_CUSTOM_FILE =
"hass_custom_file"
78 ERROR_SENTINEL = object()
82 """Wrap json_loads for get_intents."""
86 @dataclass(slots=True)
88 """Loaded intents for a language."""
91 intents_dict: dict[str, Any]
92 intent_responses: dict[str, Any]
93 error_responses: dict[str, Any]
94 language_variant: str |
None
97 @dataclass(slots=True)
99 """List of sentences and the callback for a trigger."""
102 callback: TRIGGER_CALLBACK_TYPE
105 @dataclass(slots=True)
107 """Result when matching a sentence trigger in an automation."""
110 sentence_template: str |
None
111 matched_triggers: dict[int, RecognizeResult]
115 """Stages of intent matching."""
117 EXPOSED_ENTITIES_ONLY = auto()
118 """Match against exposed entities only."""
120 UNEXPOSED_ENTITIES = auto()
121 """Match against unexposed entities in Home Assistant."""
124 """Capture names that are not known to Home Assistant."""
127 @dataclass(frozen=True)
129 """Key for IntentCache."""
132 """User input text."""
135 """Language of text."""
137 device_id: str |
None
138 """Device id from user input."""
141 @dataclass(frozen=True)
143 """Value for IntentCache."""
145 result: RecognizeResult |
None
146 """Result of intent recognition."""
148 stage: IntentMatchingStage
149 """Stage where result was found."""
153 """LRU cache for intent recognition results."""
156 """Initialize cache."""
157 self.cache: OrderedDict[IntentCacheKey, IntentCacheValue] = OrderedDict()
160 def get(self, key: IntentCacheKey) -> IntentCacheValue |
None:
161 """Get value for cache or None."""
162 if key
not in self.cache:
166 self.cache.move_to_end(key)
167 return self.cache[key]
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:
173 self.cache.move_to_end(key)
174 elif len(self.cache) >= self.
capacitycapacity:
176 self.cache.popitem(last=
False)
178 self.cache[key] = value
181 """Clear the cache."""
186 """Generate language codes with and without region."""
189 parts = re.split(
r"([-_])", language)
191 lang, sep, region = parts
194 yield f
"{lang}-{region}"
201 hass: core.HomeAssistant,
202 entity_component: EntityComponent[ConversationEntity],
203 config_intents: dict[str, Any],
205 """Set up entity registry listener for the default agent."""
207 await entity_component.async_add_entities([entity])
208 hass.data[DATA_DEFAULT_ENTITY] = entity
211 def async_entity_state_listener(
214 """Set expose flag on new entities."""
218 def async_hass_started(hass: core.HomeAssistant) ->
None:
219 """Set expose flag on all entities."""
220 for state
in hass.states.async_all():
224 ha_start.async_at_started(hass, async_hass_started)
228 """Default agent for conversation agent."""
230 _attr_name =
"Home Assistant"
231 _attr_supported_features = ConversationEntityFeature.CONTROL
234 self, hass: core.HomeAssistant, config_intents: dict[str, Any]
236 """Initialize the default agent."""
238 self._lang_intents: dict[str, LanguageIntents | object] = {}
241 self._config_intents: dict[str, Any] = config_intents
242 self.
_slot_lists_slot_lists: dict[str, SlotList] |
None =
None
249 self._trigger_sentences: list[TriggerData] = []
259 """Return a list of supported languages."""
260 return get_languages()
264 self, event_data: er.EventEntityRegistryUpdatedData
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
273 """Filter state changed events."""
274 return not event_data[
"old_state"]
or not event_data[
"new_state"]
278 """Listen for changes that can invalidate slot list."""
283 ar.EVENT_AREA_REGISTRY_UPDATED,
287 fr.EVENT_FLOOR_REGISTRY_UPDATED,
291 er.EVENT_ENTITY_REGISTRY_UPDATED,
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
310 if lang_intents
is None:
312 _LOGGER.warning(
"No intents were loaded for language: %s", language)
320 text_lower = user_input.text.strip().lower()
321 slot_lists[
"name"] = TextSlotList(
328 start = time.monotonic()
330 result = await self.
hasshasshass.async_add_executor_job(
341 "Recognize done in %.2f seconds",
342 time.monotonic() - start,
347 async
def async_process(self, user_input: ConversationInput) -> ConversationResult:
348 """Process a sentence."""
354 trigger_result, user_input
358 response = intent.IntentResponse(
359 language=user_input.language
or self.
hasshasshass.config.language
361 response.response_type = intent.IntentResponseType.ACTION_DONE
362 response.async_set_speech(response_text)
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
384 _LOGGER.debug(
"No intent was matched for '%s'", user_input.text)
387 intent.IntentResponseErrorCode.NO_INTENT_MATCH,
392 if result.unmatched_entities:
395 "Recognized intent '%s' for template '%s' but had unmatched: %s",
398 result.intent_sentence.text
399 if result.intent_sentence
is not None
402 result.unmatched_entities_list,
407 intent.IntentResponseErrorCode.NO_VALID_TARGETS,
409 error_response_type, lang_intents, **error_response_args
416 assert lang_intents
is not None
419 slots: dict[str, Any] = {
421 "value": entity.value,
422 "text": entity.text
or entity.value,
424 for entity
in result.entities_list
428 slots[
"preferred_area_id"] = {
"value": device_area.id}
430 ConversationTraceEventType.TOOL_CALL,
432 "intent_name": result.intent.name,
434 entity.name: entity.value
or entity.text
435 for entity
in result.entities_list
441 intent_response = await intent.async_handle(
450 device_id=user_input.device_id,
451 conversation_agent_id=user_input.agent_id,
453 except intent.MatchFailedError
as match_error:
460 intent.IntentResponseErrorCode.NO_VALID_TARGETS,
462 error_response_type, lang_intents, **error_response_args
466 except intent.IntentHandleError
as err:
469 _LOGGER.exception(
"Intent handling error")
472 intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
474 err.response_key
or ErrorKey.HANDLE_ERROR, lang_intents
478 except intent.IntentUnexpectedError:
479 _LOGGER.exception(
"Unexpected intent error")
482 intent.IntentResponseErrorCode.UNKNOWN,
483 self.
_get_error_text_get_error_text(ErrorKey.HANDLE_ERROR, lang_intents),
488 (
not intent_response.speech)
489 and (intent_response.intent
is not None)
490 and (response_key := result.response)
493 response_template_str = lang_intents.intent_responses.get(
494 result.intent.name, {}
496 if response_template_str:
497 response_template = template.Template(response_template_str, self.
hasshasshass)
499 language, response_template, intent_response, result
501 intent_response.async_set_speech(speech)
504 response=intent_response, conversation_id=conversation_id
509 user_input: ConversationInput,
510 lang_intents: LanguageIntents,
511 slot_lists: dict[str, SlotList],
512 intent_context: dict[str, Any] |
None,
514 strict_intents_only: bool,
515 ) -> RecognizeResult |
None:
516 """Search intents for a match to user input."""
517 skip_exposed_match =
False
521 text=user_input.text, language=language, device_id=user_input.device_id
524 if cache_value
is not None:
525 if (cache_value.result
is not None)
and (
526 cache_value.stage == IntentMatchingStage.EXPOSED_ENTITIES_ONLY
528 _LOGGER.debug(
"Got cached result for exposed entities")
529 return cache_value.result
533 skip_exposed_match =
True
535 if not skip_exposed_match:
536 start_time = time.monotonic()
538 user_input, lang_intents, slot_lists, intent_context, language
541 "Checked exposed entities in %s second(s)",
542 time.monotonic() - start_time,
549 result=strict_result,
550 stage=IntentMatchingStage.EXPOSED_ENTITIES_ONLY,
554 if strict_result
is not None:
558 if strict_intents_only:
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
568 _LOGGER.debug(
"Got cached result for all entities")
569 return cache_value.result
573 skip_unexposed_entities_match =
True
575 if not skip_unexposed_entities_match:
576 unexposed_entities_slot_lists = {
581 start_time = time.monotonic()
585 unexposed_entities_slot_lists,
591 "Checked all entities in %s second(s)", time.monotonic() - start_time
598 result=strict_result, stage=IntentMatchingStage.UNEXPOSED_ENTITIES
602 if strict_result
is not None:
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
613 _LOGGER.debug(
"Got cached result for fuzzy match")
614 return cache_value.result
617 skip_fuzzy_match =
True
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(
627 lang_intents.intents,
628 slot_lists=slot_lists,
629 intent_context=intent_context,
630 allow_unmatched_entities=
True,
632 if result.text_chunks_matched < 1:
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
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
652 num_unmatched_entities += 1
655 (maybe_result
is None)
656 or (num_matched_entities > best_num_matched_entities)
659 (num_matched_entities == best_num_matched_entities)
660 and (num_unmatched_entities < best_num_unmatched_entities)
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)
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)
674 result.text_chunks_matched
675 > maybe_result.text_chunks_matched
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)
684 (
"name" in result.entities)
685 or (
"name" in result.unmatched_entities)
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
701 "Did fuzzy match in %s second(s)", time.monotonic() - start_time
707 """Get filtered slot list with unexposed entity names in Home Assistant."""
713 name_tuple[0].lower(),
714 TextSlotValue.from_tuple(name_tuple, allow_template=
False),
718 text_lower = text.strip().lower()
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)
732 for state
in self.
hasshasshass.states.async_all():
734 if exposed
and (
not entity_exposed):
738 if (
not exposed)
and entity_exposed:
743 context = {
"domain": state.domain}
746 for attr
in DEFAULT_EXPOSED_ATTRIBUTES:
747 if attr
not in state.attributes:
749 context[attr] = state.attributes[attr]
752 entity := entity_registry.async_get(state.entity_id)
753 )
and entity.aliases:
754 for alias
in entity.aliases:
755 alias = alias.strip()
759 yield (alias, alias, context)
762 yield (state.name, state.name, context)
766 user_input: ConversationInput,
767 lang_intents: LanguageIntents,
768 slot_lists: dict[str, SlotList],
769 intent_context: dict[str, Any] |
None,
771 ) -> RecognizeResult |
None:
772 """Search intents for a strict match to user input."""
773 return recognize_best(
775 lang_intents.intents,
776 slot_lists=slot_lists,
777 intent_context=intent_context,
779 best_metadata_key=METADATA_CUSTOM_SENTENCE,
780 best_slot_name=
"name",
786 response_template: template.Template,
787 intent_response: intent.IntentResponse,
788 recognize_result: RecognizeResult,
793 for state
in intent_response.matched_states
794 if (state_copy := core.State.from_dict(state.as_dict()))
798 for state
in intent_response.unmatched_states
799 if (state_copy := core.State.from_dict(state.as_dict()))
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
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)
816 if intent_response.matched_states:
818 elif intent_response.unmatched_states:
819 state1 = unmatched[0]
823 entity_name: entity_value.text
or entity_value.value
824 for entity_name, entity_value
in recognize_result.entities.items()
826 speech_slots.update(intent_response.speech_slots)
828 speech = response_template.async_render(
831 "slots": speech_slots,
834 template.TemplateState(self.
hasshasshass, state1)
835 if state1
is not None
841 template.TemplateState(self.
hasshasshass, state)
for state
in matched
845 template.TemplateState(self.
hasshasshass, state)
for state
in unmatched
852 if speech
is not None:
854 speech =
" ".join(speech.strip().split())
859 """Clear cached intents for a language."""
861 self._lang_intents.clear()
862 _LOGGER.debug(
"Cleared intents for all languages")
864 self._lang_intents.pop(language,
None)
865 _LOGGER.debug(
"Cleared intents for language: %s", language)
871 """Load intents for a language."""
873 language = self.
hasshasshass.config.language
878 if lang_intents
is None:
884 """Load all intents of a language with lock."""
885 if lang_intents := self._lang_intents.
get(language):
888 if lang_intents
is ERROR_SENTINEL
889 else cast(LanguageIntents, lang_intents)
894 if lang_intents := self._lang_intents.
get(language):
897 if lang_intents
is ERROR_SENTINEL
898 else cast(LanguageIntents, lang_intents)
901 start = time.monotonic()
903 result = await self.
hasshasshass.async_add_executor_job(
908 self._lang_intents[language] = ERROR_SENTINEL
910 self._lang_intents[language] = result
913 "Full intents load completed for language=%s in %.2f seconds",
915 time.monotonic() - start,
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())
928 all_language_variants = {lang.lower(): lang
for lang
in supported_langs}
932 matching_variant = all_language_variants.get(maybe_variant.lower())
934 language_variant = matching_variant
937 if not language_variant:
939 "Unable to find supported language variant for %s", language
944 lang_variant_intents = get_intents(language_variant, json_load=json_load)
946 if lang_variant_intents:
949 intents_dict = lang_variant_intents
952 "Loaded built-in intents for language=%s (%s)",
958 custom_sentences_dir = Path(
959 self.
hasshasshass.config.path(
"custom_sentences", language_variant)
961 if custom_sentences_dir.is_dir():
962 for custom_sentences_path
in custom_sentences_dir.rglob(
"*.yaml"):
963 with custom_sentences_path.open(
965 )
as custom_sentences_file:
968 custom_sentences_yaml := yaml.safe_load(custom_sentences_file),
972 "Custom sentences file does not match expected format path=%s",
973 custom_sentences_file.name,
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
989 intent_data[
"metadata"] = sentence_metadata
991 merge_dict(intents_dict, custom_sentences_yaml)
994 "Loaded custom sentences language=%s (%s), path=%s",
997 custom_sentences_path,
1001 if self._config_intents
and (
1002 self.
hasshasshass.config.language
in (language, language_variant)
1004 hass_config_path = self.
hasshasshass.config.path()
1012 "sentences": sentences,
1014 METADATA_CUSTOM_SENTENCE:
True,
1015 METADATA_CUSTOM_FILE: hass_config_path,
1020 for intent_name, sentences
in self._config_intents.items()
1025 "Loaded intents from configuration.yaml",
1028 if not intents_dict:
1031 intents = Intents.from_dict(intents_dict)
1034 responses_dict = intents_dict.get(
"responses", {})
1035 intent_responses = responses_dict.get(
"intents", {})
1036 error_responses = responses_dict.get(
"errors", {})
1048 """Clear slot lists when a registry has changed."""
1050 _LOGGER.debug(
"Clearing slot lists")
1065 """Create slot lists with areas and entity names/aliases."""
1069 start = time.monotonic()
1079 _LOGGER.debug(
"Exposed entities: %s", exposed_entity_names)
1082 areas = ar.async_get(self.
hasshasshass)
1084 for area
in areas.async_list_areas():
1085 area_names.append((area.name, area.name))
1086 if not area.aliases:
1089 for alias
in area.aliases:
1090 alias = alias.strip()
1094 area_names.append((alias, alias))
1097 floors = fr.async_get(self.
hasshasshass)
1099 for floor
in floors.async_list_floors():
1100 floor_names.append((floor.name, floor.name))
1101 if not floor.aliases:
1104 for alias
in floor.aliases:
1105 alias = alias.strip()
1109 floor_names.append((alias, floor.name))
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()
1120 "area": TextSlotList.from_tuples(area_names, allow_template=
False),
1122 "floor": TextSlotList.from_tuples(floor_names, allow_template=
False),
1128 "Created slot lists in %.2f seconds",
1129 time.monotonic() - start,
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:
1142 if device_area
is None:
1145 return {
"area": {
"value": device_area.name,
"text": device_area.name}}
1148 """Return area object for given device identifier."""
1149 if device_id
is None:
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):
1157 areas = ar.async_get(self.
hasshasshass)
1159 return areas.async_get_area(device.area_id)
1163 error_key: ErrorKey | str,
1164 lang_intents: LanguageIntents |
None,
1167 """Get response error text by type."""
1168 if lang_intents
is None:
1169 return _DEFAULT_ERROR_TEXT
1171 if isinstance(error_key, ErrorKey):
1172 response_key = error_key.value
1174 response_key = error_key
1177 lang_intents.error_responses.get(response_key)
or _DEFAULT_ERROR_TEXT
1179 response_template = template.Template(response_str, self.
hasshasshass)
1181 return response_template.async_render(response_args)
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)
1200 """Rebuild the HassIL intents object from the current trigger sentences."""
1202 "language": self.
hasshasshass.config.language,
1207 str(trigger_id): {
"data": [{
"sentences": trigger_data.sentences}]}
1208 for trigger_id, trigger_data
in enumerate(self._trigger_sentences)
1212 trigger_intents = Intents.from_dict(intents_dict)
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:
1221 for wildcard_name
in wildcard_names:
1222 trigger_intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
1226 _LOGGER.debug(
"Rebuilt trigger intents: %s", intents_dict)
1230 """Unregister a set of trigger sentences."""
1231 self._trigger_sentences.
remove(trigger_data)
1237 self, user_input: ConversationInput
1238 ) -> SentenceTriggerResult |
None:
1239 """Try to match sentence against registered trigger sentences.
1241 Calls the registered callbacks if there's a match and returns a sentence
1244 if not self._trigger_sentences:
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
1260 trigger_id =
int(result.intent.name)
1261 if trigger_id
in matched_triggers:
1265 matched_triggers[trigger_id] = result
1267 if not matched_triggers:
1272 "'%s' matched %s trigger(s): %s",
1274 len(matched_triggers),
1275 list(matched_triggers),
1279 user_input.text, matched_template, matched_triggers
1283 self, result: SentenceTriggerResult, user_input: ConversationInput
1285 """Run sentence trigger callbacks and return response text."""
1288 trigger_callbacks = [
1289 self._trigger_sentences[trigger_id].callback(
1290 user_input.text, trigger_result, user_input.device_id
1292 for trigger_id, trigger_result
in result.matched_triggers.items()
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:
1306 response_text = trigger_response
1307 response_set_by_trigger =
True
1310 if response_set_by_trigger:
1312 response_text = response_text
or ""
1313 elif not response_text:
1315 language = user_input.language
or self.
hasshasshass.config.language
1316 translations = await translation.async_get_translations(
1317 self.
hasshasshass, language, DOMAIN, [DOMAIN]
1319 response_text = translations.get(
1320 f
"component.{DOMAIN}.conversation.agent.done",
"Done"
1323 return response_text
1326 self, user_input: ConversationInput
1328 """Try to input sentence against sentence triggers and return response text.
1330 Returns None if no match occurred.
1339 user_input: ConversationInput,
1340 ) -> intent.IntentResponse |
None:
1341 """Try to match sentence against registered intents and return response.
1343 Only performs strict matching with exposed entities and exact wording.
1344 Returns None if no match occurred.
1347 if not isinstance(result, RecognizeResult):
1354 return conversation_result.response
1359 error_code: intent.IntentResponseErrorCode,
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)
1370 """Get key and template arguments for error when there are unmatched intent entities/slots."""
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
1379 if unmatched_area := unmatched_text.get(
"area"):
1381 return ErrorKey.NO_AREA, {
"area": unmatched_area}
1383 if unmatched_floor := unmatched_text.get(
"floor"):
1385 return ErrorKey.NO_FLOOR, {
"floor": unmatched_floor}
1388 matched_area: str |
None =
None
1389 if matched_area_entity := result.entities.get(
"area"):
1390 matched_area = matched_area_entity.text.strip()
1392 matched_floor: str |
None =
None
1393 if matched_floor_entity := result.entities.get(
"floor"):
1394 matched_floor = matched_floor_entity.text.strip()
1396 if unmatched_name := unmatched_text.get(
"name"):
1399 return ErrorKey.NO_ENTITY_IN_AREA, {
1400 "entity": unmatched_name,
1401 "area": matched_area,
1405 return ErrorKey.NO_ENTITY_IN_FLOOR, {
1406 "entity": unmatched_name,
1407 "floor": matched_floor,
1411 return ErrorKey.NO_ENTITY, {
"entity": unmatched_name}
1414 return ErrorKey.NO_INTENT, {}
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."""
1423 constraints, result = match_error.constraints, match_error.result
1424 reason = result.no_match_reason
1428 in (intent.MatchFailedReason.DEVICE_CLASS, intent.MatchFailedReason.DOMAIN)
1429 )
and constraints.device_classes:
1430 device_class = next(iter(constraints.device_classes))
1431 if constraints.area_name:
1433 return ErrorKey.NO_DEVICE_CLASS_IN_AREA, {
1434 "device_class": device_class,
1435 "area": constraints.area_name,
1439 return ErrorKey.NO_DEVICE_CLASS, {
"device_class": device_class}
1441 if (reason == intent.MatchFailedReason.DOMAIN)
and constraints.domains:
1442 domain = next(iter(constraints.domains))
1443 if constraints.area_name:
1445 return ErrorKey.NO_DOMAIN_IN_AREA, {
1447 "area": constraints.area_name,
1450 if constraints.floor_name:
1452 return ErrorKey.NO_DOMAIN_IN_FLOOR, {
1454 "floor": constraints.floor_name,
1458 return ErrorKey.NO_DOMAIN, {
"domain": domain}
1460 if reason == intent.MatchFailedReason.DUPLICATE_NAME:
1461 if constraints.floor_name:
1463 return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, {
1464 "entity": result.no_match_name,
1465 "floor": constraints.floor_name,
1468 if constraints.area_name:
1470 return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, {
1471 "entity": result.no_match_name,
1472 "area": constraints.area_name,
1475 return ErrorKey.DUPLICATE_ENTITIES, {
"entity": result.no_match_name}
1477 if reason == intent.MatchFailedReason.INVALID_AREA:
1479 return ErrorKey.NO_AREA, {
"area": result.no_match_name}
1481 if reason == intent.MatchFailedReason.INVALID_FLOOR:
1483 return ErrorKey.NO_FLOOR, {
"floor": result.no_match_name}
1485 if reason == intent.MatchFailedReason.FEATURE:
1487 return ErrorKey.FEATURE_NOT_SUPPORTED, {}
1489 if reason == intent.MatchFailedReason.STATE:
1491 assert constraints.states
1492 state = next(iter(constraints.states))
1493 if constraints.domains:
1495 domain = next(iter(constraints.domains))
1496 state = translation.async_translate_state(
1497 hass, state, domain,
None,
None,
None
1500 return ErrorKey.ENTITY_WRONG_STATE, {
"state": state}
1502 if reason == intent.MatchFailedReason.ASSISTANT:
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,
1510 if constraints.floor_name:
1511 return ErrorKey.NO_ENTITY_IN_FLOOR_EXPOSED, {
1512 "entity": constraints.name,
1513 "floor": constraints.floor_name,
1515 return ErrorKey.NO_ENTITY_EXPOSED, {
"entity": constraints.name}
1517 if constraints.device_classes:
1518 device_class = next(iter(constraints.device_classes))
1520 if constraints.area_name:
1521 return ErrorKey.NO_DEVICE_CLASS_IN_AREA_EXPOSED, {
1522 "device_class": device_class,
1523 "area": constraints.area_name,
1525 if constraints.floor_name:
1526 return ErrorKey.NO_DEVICE_CLASS_IN_FLOOR_EXPOSED, {
1527 "device_class": device_class,
1528 "floor": constraints.floor_name,
1530 return ErrorKey.NO_DEVICE_CLASS_EXPOSED, {
"device_class": device_class}
1532 if constraints.domains:
1533 domain = next(iter(constraints.domains))
1535 if constraints.area_name:
1536 return ErrorKey.NO_DOMAIN_IN_AREA_EXPOSED, {
1538 "area": constraints.area_name,
1540 if constraints.floor_name:
1541 return ErrorKey.NO_DOMAIN_IN_FLOOR_EXPOSED, {
1543 "floor": constraints.floor_name,
1545 return ErrorKey.NO_DOMAIN_EXPOSED, {
"domain": domain}
1548 return ErrorKey.NO_INTENT, {}
1552 """Collect list reference names recursively."""
1553 if isinstance(expression, Sequence):
1554 seq: Sequence = expression
1555 for item
in seq.items:
1557 elif isinstance(expression, ListReference):
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)
None _unregister_trigger(self, TriggerData trigger_data)
SentenceTriggerResult|None async_recognize_sentence_trigger(self, ConversationInput user_input)
list[str] supported_languages(self)
None async_reload(self, str|None language=None)
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)
None async_prepare(self, str|None language=None)
ConversationResult _async_process_intent_result(self, RecognizeResult|None result, ConversationInput user_input)
dict[str, SlotList] _make_slot_lists(self)
Iterable[tuple[str, str, dict[str, Any]]] _get_entity_name_tuples(self, bool exposed)
bool _filter_state_changes(self, core.EventStateChangedData event_data)
None _rebuild_trigger_intents(self)
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)
None _listen_clear_slot_list(self)
str|None async_handle_sentence_triggers(self, ConversationInput user_input)
TextSlotList _get_unexposed_entity_names(self, str text)
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 __init__(self, int capacity)
None put(self, IntentCacheKey key, IntentCacheValue value)
bool remove(self, _T matcher)
web.Response get(self, web.Request request, str config_key)
JsonObjectType json_load(IO[str] fp)
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)
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)
JsonObjectType json_loads_object(bytes|bytearray|memoryview|str obj)