Home Assistant Unofficial Reference 2024.12.1
http.py
Go to the documentation of this file.
1 """HTTP endpoints for conversation integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Iterable
6 from typing import Any
7 
8 from aiohttp import web
9 from hassil.recognize import MISSING_ENTITY, RecognizeResult
10 from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
11 import voluptuous as vol
12 
13 from homeassistant.components import http, websocket_api
14 from homeassistant.components.http.data_validator import RequestDataValidator
15 from homeassistant.const import MATCH_ALL
16 from homeassistant.core import HomeAssistant, State, callback
17 from homeassistant.helpers import config_validation as cv, intent
18 from homeassistant.util import language as language_util
19 
20 from .agent_manager import (
21  agent_id_validator,
22  async_converse,
23  async_get_agent,
24  get_agent_manager,
25 )
26 from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
27 from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE, DefaultAgent
28 from .entity import ConversationEntity
29 from .models import ConversationInput
30 
31 
32 @callback
33 def async_setup(hass: HomeAssistant) -> None:
34  """Set up the HTTP API for the conversation integration."""
35  hass.http.register_view(ConversationProcessView())
36  websocket_api.async_register_command(hass, websocket_process)
37  websocket_api.async_register_command(hass, websocket_prepare)
38  websocket_api.async_register_command(hass, websocket_list_agents)
39  websocket_api.async_register_command(hass, websocket_hass_agent_debug)
40 
41 
42 @websocket_api.websocket_command( { vol.Required("type"): "conversation/process",
43  vol.Required("text"): str,
44  vol.Optional("conversation_id"): vol.Any(str, None),
45  vol.Optional("language"): str,
46  vol.Optional("agent_id"): agent_id_validator,
47  }
48 )
49 @websocket_api.async_response
50 async def websocket_process(
51  hass: HomeAssistant,
53  msg: dict[str, Any],
54 ) -> None:
55  """Process text."""
56  result = await async_converse(
57  hass=hass,
58  text=msg["text"],
59  conversation_id=msg.get("conversation_id"),
60  context=connection.context(msg),
61  language=msg.get("language"),
62  agent_id=msg.get("agent_id"),
63  )
64  connection.send_result(msg["id"], result.as_dict())
65 
66 
67 @websocket_api.websocket_command( { "type": "conversation/prepare", vol.Optional("language"): str,
68  vol.Optional("agent_id"): agent_id_validator,
69  }
70 )
71 @websocket_api.async_response
72 async def websocket_prepare(
73  hass: HomeAssistant,
75  msg: dict[str, Any],
76 ) -> None:
77  """Reload intents."""
78  agent = async_get_agent(hass, msg.get("agent_id"))
79 
80  if agent is None:
81  connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Agent not found")
82  return
83 
84  await agent.async_prepare(msg.get("language"))
85  connection.send_result(msg["id"])
86 
87 
88 @websocket_api.websocket_command( { vol.Required("type"): "conversation/agent/list",
89  vol.Optional("language"): str,
90  vol.Optional("country"): str,
91  }
92 )
93 @websocket_api.async_response
94 async def websocket_list_agents(
95  hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
96 ) -> None:
97  """List conversation agents and, optionally, if they support a given language."""
98  country = msg.get("country")
99  language = msg.get("language")
100  agents = []
101 
102  for entity in hass.data[DATA_COMPONENT].entities:
103  supported_languages = entity.supported_languages
104  if language and supported_languages != MATCH_ALL:
105  supported_languages = language_util.matches(
106  language, supported_languages, country
107  )
108 
109  name = entity.entity_id
110  if state := hass.states.get(entity.entity_id):
111  name = state.name
112 
113  agents.append(
114  {
115  "id": entity.entity_id,
116  "name": name,
117  "supported_languages": supported_languages,
118  }
119  )
120 
121  manager = get_agent_manager(hass)
122 
123  for agent_info in manager.async_get_agent_info():
124  agent = manager.async_get_agent(agent_info.id)
125  assert agent is not None
126 
127  if isinstance(agent, ConversationEntity):
128  continue
129 
130  supported_languages = agent.supported_languages
131  if language and supported_languages != MATCH_ALL:
132  supported_languages = language_util.matches(
133  language, supported_languages, country
134  )
135 
136  agent_dict: dict[str, Any] = {
137  "id": agent_info.id,
138  "name": agent_info.name,
139  "supported_languages": supported_languages,
140  }
141  agents.append(agent_dict)
142 
143  connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents}))
144 
145 
146 @websocket_api.websocket_command( { vol.Required("type"): "conversation/agent/homeassistant/debug",
147  vol.Required("sentences"): [str],
148  vol.Optional("language"): str,
149  vol.Optional("device_id"): vol.Any(str, None),
150  }
151 )
152 @websocket_api.async_response
154  hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
155 ) -> None:
156  """Return intents that would be matched by the default agent for a list of sentences."""
157  agent = hass.data.get(DATA_DEFAULT_ENTITY)
158  assert isinstance(agent, DefaultAgent)
159 
160  # Return results for each sentence in the same order as the input.
161  result_dicts: list[dict[str, Any] | None] = []
162  for sentence in msg["sentences"]:
163  user_input = ConversationInput(
164  text=sentence,
165  context=connection.context(msg),
166  conversation_id=None,
167  device_id=msg.get("device_id"),
168  language=msg.get("language", hass.config.language),
169  agent_id=None,
170  )
171  result_dict: dict[str, Any] | None = None
172 
173  if trigger_result := await agent.async_recognize_sentence_trigger(user_input):
174  result_dict = {
175  # Matched a user-defined sentence trigger.
176  # We can't provide the response here without executing the
177  # trigger.
178  "match": True,
179  "source": "trigger",
180  "sentence_template": trigger_result.sentence_template or "",
181  }
182  elif intent_result := await agent.async_recognize_intent(user_input):
183  successful_match = not intent_result.unmatched_entities
184  result_dict = {
185  # Name of the matching intent (or the closest)
186  "intent": {
187  "name": intent_result.intent.name,
188  },
189  # Slot values that would be received by the intent
190  "slots": { # direct access to values
191  entity_key: entity.text or entity.value
192  for entity_key, entity in intent_result.entities.items()
193  },
194  # Extra slot details, such as the originally matched text
195  "details": {
196  entity_key: {
197  "name": entity.name,
198  "value": entity.value,
199  "text": entity.text,
200  }
201  for entity_key, entity in intent_result.entities.items()
202  },
203  # Entities/areas/etc. that would be targeted
204  "targets": {},
205  # True if match was successful
206  "match": successful_match,
207  # Text of the sentence template that matched (or was closest)
208  "sentence_template": "",
209  # When match is incomplete, this will contain the best slot guesses
210  "unmatched_slots": _get_unmatched_slots(intent_result),
211  }
212 
213  if successful_match:
214  result_dict["targets"] = {
215  state.entity_id: {"matched": is_matched}
216  for state, is_matched in _get_debug_targets(hass, intent_result)
217  }
218 
219  if intent_result.intent_sentence is not None:
220  result_dict["sentence_template"] = intent_result.intent_sentence.text
221 
222  # Inspect metadata to determine if this matched a custom sentence
223  if intent_result.intent_metadata and intent_result.intent_metadata.get(
224  METADATA_CUSTOM_SENTENCE
225  ):
226  result_dict["source"] = "custom"
227  result_dict["file"] = intent_result.intent_metadata.get(
228  METADATA_CUSTOM_FILE
229  )
230  else:
231  result_dict["source"] = "builtin"
232 
233  result_dicts.append(result_dict)
234 
235  connection.send_result(msg["id"], {"results": result_dicts})
236 
237 
239  hass: HomeAssistant,
240  result: RecognizeResult,
241 ) -> Iterable[tuple[State, bool]]:
242  """Yield state/is_matched pairs for a hassil recognition."""
243  entities = result.entities
244 
245  name: str | None = None
246  area_name: str | None = None
247  domains: set[str] | None = None
248  device_classes: set[str] | None = None
249  state_names: set[str] | None = None
250 
251  if "name" in entities:
252  name = str(entities["name"].value)
253 
254  if "area" in entities:
255  area_name = str(entities["area"].value)
256 
257  if "domain" in entities:
258  domains = set(cv.ensure_list(entities["domain"].value))
259 
260  if "device_class" in entities:
261  device_classes = set(cv.ensure_list(entities["device_class"].value))
262 
263  if "state" in entities:
264  # HassGetState only
265  state_names = set(cv.ensure_list(entities["state"].value))
266 
267  if (
268  (name is None)
269  and (area_name is None)
270  and (not domains)
271  and (not device_classes)
272  and (not state_names)
273  ):
274  # Avoid "matching" all entities when there is no filter
275  return
276 
277  states = intent.async_match_states(
278  hass,
279  name=name,
280  area_name=area_name,
281  domains=domains,
282  device_classes=device_classes,
283  )
284 
285  for state in states:
286  # For queries, a target is "matched" based on its state
287  is_matched = (state_names is None) or (state.state in state_names)
288  yield state, is_matched
289 
290 
292  result: RecognizeResult,
293 ) -> dict[str, str | int | float]:
294  """Return a dict of unmatched text/range slot entities."""
295  unmatched_slots: dict[str, str | int | float] = {}
296  for entity in result.unmatched_entities_list:
297  if isinstance(entity, UnmatchedTextEntity):
298  if entity.text == MISSING_ENTITY:
299  # Don't report <missing> since these are just missing context
300  # slots.
301  continue
302 
303  unmatched_slots[entity.name] = entity.text
304  elif isinstance(entity, UnmatchedRangeEntity):
305  unmatched_slots[entity.name] = entity.value
306 
307  return unmatched_slots
308 
309 
310 class ConversationProcessView(http.HomeAssistantView):
311  """View to process text."""
312 
313  url = "/api/conversation/process"
314  name = "api:conversation:process"
315 
316  @RequestDataValidator( vol.Schema( { vol.Required("text"): str,
317  vol.Optional("conversation_id"): str,
318  vol.Optional("language"): str,
319  vol.Optional("agent_id"): agent_id_validator,
320  }
321  )
322  )
323  async def post(self, request: web.Request, data: dict[str, str]) -> web.Response:
324  """Send a request for processing."""
325  hass = request.app[http.KEY_HASS]
326 
327  result = await async_converse(
328  hass,
329  text=data["text"],
330  conversation_id=data.get("conversation_id"),
331  context=self.context(request),
332  language=data.get("language"),
333  agent_id=data.get("agent_id"),
334  )
335 
336  return self.json(result.as_dict())
337 
web.Response post(self, web.Request request, dict[str, str] data)
Definition: http.py:332
AgentManager get_agent_manager(HomeAssistant hass)
ConversationResult async_converse(HomeAssistant hass, str text, str|None conversation_id, Context context, str|None language=None, str|None agent_id=None, str|None device_id=None)
AbstractConversationAgent|ConversationEntity|None async_get_agent(HomeAssistant hass, str|None agent_id=None)
Iterable[tuple[State, bool]] _get_debug_targets(HomeAssistant hass, RecognizeResult result)
Definition: http.py:250
None async_setup(HomeAssistant hass)
Definition: http.py:33
None websocket_list_agents(HomeAssistant hass, websocket_api.ActiveConnection connection, dict msg)
Definition: http.py:103
None websocket_process(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
Definition: http.py:56
None websocket_prepare(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
Definition: http.py:81
dict[str, str|int|float] _get_unmatched_slots(RecognizeResult result)
Definition: http.py:302
None websocket_hass_agent_debug(HomeAssistant hass, websocket_api.ActiveConnection connection, dict msg)
Definition: http.py:164