mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 01:11:51 -04:00
Add missing features to Wyoming conversation agent (#164278)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Support for Wyoming intent recognition services."""
|
||||
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from wyoming.asr import Transcript
|
||||
from wyoming.client import AsyncTcpClient
|
||||
@@ -10,6 +11,7 @@ from wyoming.intent import Intent, NotRecognized
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -89,8 +91,11 @@ class WyomingConversationEntity(
|
||||
self._attr_unique_id = f"{config_entry.entry_id}-conversation"
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
def supported_languages(self) -> list[str] | Literal["*"]:
|
||||
"""Return a list of supported languages."""
|
||||
if not self._supported_languages:
|
||||
return MATCH_ALL
|
||||
|
||||
return self._supported_languages
|
||||
|
||||
async def async_process(
|
||||
@@ -100,11 +105,17 @@ class WyomingConversationEntity(
|
||||
conversation_id = user_input.conversation_id or ulid_util.ulid_now()
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
|
||||
context = {"conversation_id": conversation_id}
|
||||
if user_input.satellite_id:
|
||||
context["satellite_id"] = user_input.satellite_id
|
||||
|
||||
try:
|
||||
async with AsyncTcpClient(self.service.host, self.service.port) as client:
|
||||
await client.write_event(
|
||||
Transcript(
|
||||
user_input.text, context={"conversation_id": conversation_id}
|
||||
user_input.text,
|
||||
context=context,
|
||||
language=user_input.language,
|
||||
).event()
|
||||
)
|
||||
|
||||
@@ -138,6 +149,8 @@ class WyomingConversationEntity(
|
||||
intent_slots,
|
||||
text_input=user_input.text,
|
||||
language=user_input.language,
|
||||
satellite_id=user_input.satellite_id,
|
||||
device_id=user_input.device_id,
|
||||
)
|
||||
|
||||
if (not intent_response.speech) and recognized_intent.text:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from wyoming.asr import Transcript
|
||||
from wyoming.event import Event
|
||||
from wyoming.info import (
|
||||
AsrModel,
|
||||
@@ -172,13 +173,14 @@ EMPTY_INFO = Info()
|
||||
class MockAsyncTcpClient:
|
||||
"""Mock AsyncTcpClient."""
|
||||
|
||||
def __init__(self, responses: list[Event]) -> None:
|
||||
def __init__(self, responses: list[Event | None]) -> None:
|
||||
"""Initialize."""
|
||||
self.host: str | None = None
|
||||
self.port: int | None = None
|
||||
self.written: list[Event] = []
|
||||
self.responses = responses
|
||||
self.is_connected: bool | None = None
|
||||
self.transcript: Transcript | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect."""
|
||||
@@ -192,6 +194,9 @@ class MockAsyncTcpClient:
|
||||
"""Send."""
|
||||
self.written.append(event)
|
||||
|
||||
if Transcript.is_type(event.type):
|
||||
self.transcript = Transcript.from_event(event)
|
||||
|
||||
async def read_event(self) -> Event | None:
|
||||
"""Receive."""
|
||||
await asyncio.sleep(0) # force context switch
|
||||
|
||||
@@ -4,24 +4,29 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from wyoming.asr import Transcript
|
||||
from wyoming.handle import Handled, NotHandled
|
||||
from wyoming.info import Info
|
||||
from wyoming.intent import Entity, Intent, NotRecognized
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
|
||||
from . import MockAsyncTcpClient
|
||||
from . import HANDLE_INFO, INTENT_INFO, MockAsyncTcpClient
|
||||
|
||||
|
||||
async def test_intent(hass: HomeAssistant, init_wyoming_intent: ConfigEntry) -> None:
|
||||
"""Test when an intent is recognized."""
|
||||
agent_id = "conversation.test_intent"
|
||||
|
||||
conversation_id = "conversation-1234"
|
||||
satellite_id = "satellite-1234"
|
||||
device_id = "device-1234"
|
||||
|
||||
test_intent = Intent(
|
||||
name="TestIntent",
|
||||
entities=[Entity(name="entity", value="value")],
|
||||
@@ -36,13 +41,16 @@ async def test_intent(hass: HomeAssistant, init_wyoming_intent: ConfigEntry) ->
|
||||
async def async_handle(self, intent_obj: intent.Intent):
|
||||
"""Handle the intent."""
|
||||
assert intent_obj.slots.get("entity", {}).get("value") == "value"
|
||||
assert intent_obj.satellite_id == satellite_id
|
||||
assert intent_obj.device_id == device_id
|
||||
return intent_obj.create_response()
|
||||
|
||||
intent.async_register(hass, TestIntentHandler())
|
||||
|
||||
client = MockAsyncTcpClient([test_intent.event()])
|
||||
with patch(
|
||||
"homeassistant.components.wyoming.conversation.AsyncTcpClient",
|
||||
MockAsyncTcpClient([test_intent.event()]),
|
||||
client,
|
||||
):
|
||||
result = await conversation.async_converse(
|
||||
hass=hass,
|
||||
@@ -51,8 +59,18 @@ async def test_intent(hass: HomeAssistant, init_wyoming_intent: ConfigEntry) ->
|
||||
context=Context(),
|
||||
language=hass.config.language,
|
||||
agent_id=agent_id,
|
||||
satellite_id=satellite_id,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
# Ensure language and context are sent
|
||||
assert client.transcript is not None
|
||||
assert client.transcript.language == hass.config.language
|
||||
assert client.transcript.context == {
|
||||
"conversation_id": conversation_id,
|
||||
"satellite_id": satellite_id,
|
||||
}
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.speech, "No speech"
|
||||
assert result.response.speech.get("plain", {}).get("speech") == "success"
|
||||
@@ -123,12 +141,13 @@ async def test_not_recognized(
|
||||
async def test_handle(hass: HomeAssistant, init_wyoming_handle: ConfigEntry) -> None:
|
||||
"""Test when an intent is handled."""
|
||||
agent_id = "conversation.test_handle"
|
||||
|
||||
conversation_id = "conversation-1234"
|
||||
satellite_id = "satellite-1234"
|
||||
|
||||
client = MockAsyncTcpClient([Handled(text="success").event()])
|
||||
with patch(
|
||||
"homeassistant.components.wyoming.conversation.AsyncTcpClient",
|
||||
MockAsyncTcpClient([Handled(text="success").event()]),
|
||||
client,
|
||||
):
|
||||
result = await conversation.async_converse(
|
||||
hass=hass,
|
||||
@@ -137,8 +156,17 @@ async def test_handle(hass: HomeAssistant, init_wyoming_handle: ConfigEntry) ->
|
||||
context=Context(),
|
||||
language=hass.config.language,
|
||||
agent_id=agent_id,
|
||||
satellite_id=satellite_id,
|
||||
)
|
||||
|
||||
# Ensure language and context are sent
|
||||
assert client.transcript is not None
|
||||
assert client.transcript.language == hass.config.language
|
||||
assert client.transcript.context == {
|
||||
"conversation_id": conversation_id,
|
||||
"satellite_id": satellite_id,
|
||||
}
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.speech, "No speech"
|
||||
assert result.response.speech.get("plain", {}).get("speech") == "success"
|
||||
@@ -222,3 +250,45 @@ async def test_oserror(
|
||||
assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN
|
||||
assert result.response.speech, "No speech"
|
||||
assert result.response.speech.get("plain", {}).get("speech") == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_entry_fixture", "info_obj", "info_kwargs", "agent_id"),
|
||||
[
|
||||
(
|
||||
"intent_config_entry",
|
||||
INTENT_INFO.intent[0].models[0],
|
||||
{"intent": INTENT_INFO.intent},
|
||||
"conversation.test_intent",
|
||||
),
|
||||
(
|
||||
"handle_config_entry",
|
||||
HANDLE_INFO.handle[0].models[0],
|
||||
{"handle": HANDLE_INFO.handle},
|
||||
"conversation.test_handle",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_supported_languages_empty_means_all(
|
||||
hass: HomeAssistant,
|
||||
request: pytest.FixtureRequest,
|
||||
config_entry_fixture: str,
|
||||
info_obj,
|
||||
info_kwargs: dict,
|
||||
agent_id: str,
|
||||
) -> None:
|
||||
"""Test that an empty list of supported languages means the agent supports all languages."""
|
||||
config_entry: ConfigEntry = request.getfixturevalue(config_entry_fixture)
|
||||
|
||||
with (
|
||||
patch.object(info_obj, "languages", []),
|
||||
patch(
|
||||
"homeassistant.components.wyoming.data.load_wyoming_info",
|
||||
return_value=Info(**info_kwargs),
|
||||
),
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
agent = conversation.async_get_agent(hass, agent_id)
|
||||
assert agent is not None
|
||||
assert agent.supported_languages == MATCH_ALL
|
||||
|
||||
Reference in New Issue
Block a user