From ac3804e4152525067953e1719b008ec99a3959f5 Mon Sep 17 00:00:00 2001 From: AboveColin Date: Thu, 24 Sep 2026 08:08:46 +0200 Subject: [PATCH] A voice command that acts says what it did Jev ran the action through the intent layer, which says nothing, and the Assist dialog showed no reply at all. Home Assistant's own agent picks a sentence from home-assistant-intents by what the command targeted. Jev now reads the same key off the slots it sent and renders that sentence, and says "Done." where the package has none, as for a toggle. --- custom_components/jev/conversation.py | 134 +++++++++++++++++- custom_components/jev/strings.json | 1 + custom_components/jev/translations/cs.json | 1 + custom_components/jev/translations/da.json | 1 + custom_components/jev/translations/de.json | 1 + custom_components/jev/translations/en.json | 1 + custom_components/jev/translations/es.json | 1 + custom_components/jev/translations/fr.json | 1 + custom_components/jev/translations/it.json | 1 + custom_components/jev/translations/nl.json | 1 + custom_components/jev/translations/pl.json | 1 + custom_components/jev/translations/pt-BR.json | 1 + custom_components/jev/translations/ru.json | 1 + custom_components/jev/translations/sv.json | 1 + .../jev/translations/zh-Hans.json | 1 + site-docs/conversation.md | 5 + tests/test_conversation.py | 78 ++++++++++ 17 files changed, 227 insertions(+), 4 deletions(-) diff --git a/custom_components/jev/conversation.py b/custom_components/jev/conversation.py index 359094b..bfbf49a 100644 --- a/custom_components/jev/conversation.py +++ b/custom_components/jev/conversation.py @@ -26,7 +26,7 @@ import re from collections.abc import Mapping from dataclasses import asdict, dataclass -from typing import Literal +from typing import Any, Literal from homeassistant.components import conversation from homeassistant.components.conversation.models import AbstractConversationAgent @@ -52,7 +52,7 @@ ) from .coordinator import JevRuntimeData from .entity import build_device_info -from .interpret import build_questions, interpret +from .interpret import ACTIONS, build_questions, interpret from .snapshot import async_snapshot _LOGGER = logging.getLogger(__name__) @@ -71,6 +71,7 @@ "intent_failed": "Sorry, that did not work.", "already_on": "{name} is already on.", "already_off": "{name} is already off.", + "done": "Done.", "query_not_found": "I could not find that.", "budget_spent": "The daily token budget is spent, so I cannot do that today.", "auth_failed": "TypeSafe rejected the API key. Check it in the Jev settings.", @@ -241,12 +242,23 @@ async def _async_handle_message( _LOGGER.error("intent %s failed: %s", decision.intent_type, err) return await self._speak(user_input, "intent_failed") - # Only a state question needs our lines, and loading them reads translations. + # Loading our lines reads translations, so only a reply without a sentence of + # its own loads them. + language = user_input.language or self.hass.config.language if intent_response.response_type is ha_intent.IntentResponseType.QUERY_ANSWER: - language = user_input.language or self.hass.config.language await _speak_the_answer( self.hass, intent_response, language, await self._lines(language) ) + elif ( + intent_response.response_type is ha_intent.IntentResponseType.ACTION_DONE + and not intent_response.speech + ): + spoken = await _render_action_answer( + self.hass, intent_response, decision.intent_type, decision.slots, language + ) + intent_response.async_set_speech( + spoken or (await self._lines(language))["done"] + ) return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id ) @@ -347,6 +359,9 @@ class _Shipped: state_answer: str | None writes_the_state_word: bool errors: Mapping[str, str] + # The sentences for each action intent, keyed by the response name the default + # agent's own sentence data picks. + action_answers: Mapping[str, Mapping[str, str]] # Read once per language, keyed by the language that was asked for rather than the @@ -390,6 +405,16 @@ def _load_shipped(language: str) -> _Shipped | None: for key, text in responses.get("errors", {}).items() if isinstance(text, str) and text.strip() }, + action_answers={ + intent_type: { + key: text + for key, text in ( + responses.get("intents", {}).get(intent_type) or {} + ).items() + if isinstance(text, str) and text.strip() + } + for intent_type in ACTIONS.values() + }, ) @@ -517,6 +542,107 @@ def spoken(state: State) -> _SpokenState: return ", ".join(parts) if parts else None +# One kind of device across a room or the whole house. The names differ between +# languages: English writes light_all and Polish lights_all. +_AREA_RESPONSES = {"light": ("lights_area",), "fan": ("fans_area",)} +_ALL_RESPONSES = {"light": ("light_all", "lights_all"), "fan": ("fan_all",)} + +_SLOT_REFERENCE = re.compile(r"slots\.(\w+)") + + +def _response_keys( + intent_type: str, slots: Mapping[str, Any], domain: str | None +) -> tuple[str, ...]: + """The response names to try, closest first, for what this command did. + + The default agent reads the name from the sentence it matched. This agent has + no sentence, so it reads the same thing off the slots it sent. + """ + if intent_type == "HassLightSet": + return ("brightness",) + kinds = slots.get("domain", {}).get("value") or [] + kind = kinds[0] if len(kinds) == 1 else None + closest: tuple[str, ...] + if "area" in slots: + closest = _AREA_RESPONSES.get(kind or "", ()) + elif slots.get("name", {}).get("value") == "all": + closest = _ALL_RESPONSES.get(kind or "", ()) + elif domain is not None: + # A scene is activated rather than turned on, and German says "Licht + # eingeschaltet" for one light, where English has no sentence of its own. + closest = (domain,) + else: + closest = () + return (*closest, "default") + + +async def _render_action_answer( + hass: HomeAssistant, + response: ha_intent.IntentResponse, + intent_type: str | None, + slots: Mapping[str, Any], + language: str, +) -> str | None: + """The sentence the default agent says after the same action, or None. + + `async_handle` does the action and says nothing, and the Assist dialog shows no + reply at all for an empty one. The default agent's words come from the same + package as the state answers, written by the people who translate Assist. + None when the package has nothing that fits, and the agent says "Done." instead. + """ + shipped = await _shipped(hass, language) + if shipped is None or intent_type is None: + return None + answers = shipped.action_answers.get(intent_type, {}) + states = [*response.matched_states, *response.unmatched_states] + first = states[0] if states else None + # The name slot is the device's own name, as the model picked it. "all" is not a + # name to say back. + speech_slots = { + key: value["value"] + for key, value in slots.items() + if key in ("name", "area") + and isinstance(value.get("value"), str) + and value["value"] != "all" + } | response.speech_slots + for key in _response_keys( + intent_type, slots, first.domain if first is not None else None + ): + text = answers.get(key) + if text is None: + continue + # German says "{{ slots.name }} eingeschaltet". Rendered for a room, with no + # name to put there, that is a sentence that starts with a blank. + if any(name not in speech_slots for name in _SLOT_REFERENCE.findall(text)): + continue + try: + rendered = template.Template(text, hass).async_render( + { + "slots": speech_slots, + "state": template.TemplateState(hass, first) if first else None, + "query": { + "matched": [ + template.TemplateState(hass, state) + for state in response.matched_states + ], + "unmatched": [ + template.TemplateState(hass, state) + for state in response.unmatched_states + ], + }, + }, + parse_result=False, + ) + except TemplateError as err: + _LOGGER.debug( + "the %s answer for %s did not render: %s", key, intent_type, err + ) + continue + if sentence := " ".join(str(rendered).split()): + return sentence + return None + + async def _speak_the_answer( hass: HomeAssistant, response: ha_intent.IntentResponse, diff --git a/custom_components/jev/strings.json b/custom_components/jev/strings.json index 54cf0a1..add11f9 100644 --- a/custom_components/jev/strings.json +++ b/custom_components/jev/strings.json @@ -350,6 +350,7 @@ "intent_failed": "Sorry, that did not work.", "already_on": "{name} is already on.", "already_off": "{name} is already off.", + "done": "Done.", "preview_alone": "Sent as its own request.", "preview_grouped": "Sent in one request together with: {others}.", "preview_cost": "Asked once for this preview: {tokens} input tokens, about ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/cs.json b/custom_components/jev/translations/cs.json index dd03fe5..70c7489 100644 --- a/custom_components/jev/translations/cs.json +++ b/custom_components/jev/translations/cs.json @@ -350,6 +350,7 @@ "intent_failed": "Promiňte, to se nepovedlo.", "already_on": "{name} už je zapnuto.", "already_off": "{name} už je vypnuto.", + "done": "Hotovo.", "preview_alone": "Odesláno jako vlastní požadavek.", "preview_grouped": "Odesláno v jednom požadavku spolu s: {others}.", "preview_cost": "Jednou dotázáno pro tento náhled: {tokens} vstupních tokenů, asi ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/da.json b/custom_components/jev/translations/da.json index bbdea05..0ed2896 100644 --- a/custom_components/jev/translations/da.json +++ b/custom_components/jev/translations/da.json @@ -350,6 +350,7 @@ "intent_failed": "Beklager, det virkede ikke.", "already_on": "{name} er allerede tændt.", "already_off": "{name} er allerede slukket.", + "done": "Færdig.", "preview_alone": "Sendes som sin egen anmodning.", "preview_grouped": "Sendes i én anmodning sammen med: {others}.", "preview_cost": "Spurgt én gang til denne forhåndsvisning: {tokens} input-tokens, cirka ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/de.json b/custom_components/jev/translations/de.json index 89c7dbc..55c44c6 100644 --- a/custom_components/jev/translations/de.json +++ b/custom_components/jev/translations/de.json @@ -350,6 +350,7 @@ "intent_failed": "Entschuldigung, das hat nicht geklappt.", "already_on": "{name} ist schon an.", "already_off": "{name} ist schon aus.", + "done": "Erledigt.", "preview_alone": "Geht als eigene Anfrage raus.", "preview_grouped": "Geht in einer Anfrage zusammen mit: {others}.", "preview_cost": "Einmal für diese Vorschau gefragt: {tokens} Eingabe-Tokens, etwa ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/en.json b/custom_components/jev/translations/en.json index 54cf0a1..add11f9 100644 --- a/custom_components/jev/translations/en.json +++ b/custom_components/jev/translations/en.json @@ -350,6 +350,7 @@ "intent_failed": "Sorry, that did not work.", "already_on": "{name} is already on.", "already_off": "{name} is already off.", + "done": "Done.", "preview_alone": "Sent as its own request.", "preview_grouped": "Sent in one request together with: {others}.", "preview_cost": "Asked once for this preview: {tokens} input tokens, about ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/es.json b/custom_components/jev/translations/es.json index a3e9da7..81c5f1d 100644 --- a/custom_components/jev/translations/es.json +++ b/custom_components/jev/translations/es.json @@ -350,6 +350,7 @@ "intent_failed": "Lo siento, eso no ha funcionado.", "already_on": "{name} ya está encendido.", "already_off": "{name} ya está apagado.", + "done": "Hecho.", "preview_alone": "Se envía en una solicitud propia.", "preview_grouped": "Se envía en una sola solicitud junto con: {others}.", "preview_cost": "Preguntado una vez para esta vista previa: {tokens} tokens de entrada, unos ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/fr.json b/custom_components/jev/translations/fr.json index d05d12e..82ffa86 100644 --- a/custom_components/jev/translations/fr.json +++ b/custom_components/jev/translations/fr.json @@ -350,6 +350,7 @@ "intent_failed": "Désolé, cela n'a pas fonctionné.", "already_on": "{name} est déjà allumé.", "already_off": "{name} est déjà éteint.", + "done": "C'est fait.", "preview_alone": "Envoyée dans sa propre requête.", "preview_grouped": "Envoyée dans une seule requête avec : {others}.", "preview_cost": "Posée une fois pour cet aperçu : {tokens} jetons d'entrée, environ ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/it.json b/custom_components/jev/translations/it.json index 9d88f65..3bec9ee 100644 --- a/custom_components/jev/translations/it.json +++ b/custom_components/jev/translations/it.json @@ -350,6 +350,7 @@ "intent_failed": "Scusa, non ha funzionato.", "already_on": "{name} è già acceso.", "already_off": "{name} è già spento.", + "done": "Fatto.", "preview_alone": "Inviata come richiesta a sé stante.", "preview_grouped": "Inviata in un'unica richiesta insieme a: {others}.", "preview_cost": "Chiesto una volta per questa anteprima: {tokens} token di input, circa ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/nl.json b/custom_components/jev/translations/nl.json index 48220fd..7fe8e58 100644 --- a/custom_components/jev/translations/nl.json +++ b/custom_components/jev/translations/nl.json @@ -350,6 +350,7 @@ "intent_failed": "Sorry, dat is niet gelukt.", "already_on": "{name} staat al aan.", "already_off": "{name} staat al uit.", + "done": "Gedaan.", "preview_alone": "Gaat als eigen verzoek.", "preview_grouped": "Gaat in een verzoek samen met: {others}.", "preview_cost": "Een keer gevraagd voor deze controle: {tokens} invoertokens, ongeveer ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/pl.json b/custom_components/jev/translations/pl.json index a5765eb..0a8868d 100644 --- a/custom_components/jev/translations/pl.json +++ b/custom_components/jev/translations/pl.json @@ -350,6 +350,7 @@ "intent_failed": "Przepraszam, to się nie udało.", "already_on": "{name} jest już włączone.", "already_off": "{name} jest już wyłączone.", + "done": "Gotowe.", "preview_alone": "Wysyłane jako osobne żądanie.", "preview_grouped": "Wysyłane w jednym żądaniu razem z: {others}.", "preview_cost": "Zapytano raz na potrzeby tego podglądu: {tokens} tokenów wejściowych, około ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/pt-BR.json b/custom_components/jev/translations/pt-BR.json index 22b1c26..6558e22 100644 --- a/custom_components/jev/translations/pt-BR.json +++ b/custom_components/jev/translations/pt-BR.json @@ -350,6 +350,7 @@ "intent_failed": "Desculpe, isso não funcionou.", "already_on": "{name} já está ligado.", "already_off": "{name} já está desligado.", + "done": "Pronto.", "preview_alone": "Enviada como requisição própria.", "preview_grouped": "Enviada em uma requisição junto com: {others}.", "preview_cost": "Perguntado uma vez para esta prévia: {tokens} tokens de entrada, cerca de ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/ru.json b/custom_components/jev/translations/ru.json index 1f50471..300d6bc 100644 --- a/custom_components/jev/translations/ru.json +++ b/custom_components/jev/translations/ru.json @@ -350,6 +350,7 @@ "intent_failed": "Извините, не получилось.", "already_on": "{name} уже включён.", "already_off": "{name} уже выключен.", + "done": "Готово.", "preview_alone": "Отправляется отдельным запросом.", "preview_grouped": "Отправляется одним запросом вместе с: {others}.", "preview_cost": "Один вопрос для этого предпросмотра: {tokens} входных токенов, примерно ${cost}, {ms} мс.", diff --git a/custom_components/jev/translations/sv.json b/custom_components/jev/translations/sv.json index d49d5b2..83d0b03 100644 --- a/custom_components/jev/translations/sv.json +++ b/custom_components/jev/translations/sv.json @@ -350,6 +350,7 @@ "intent_failed": "Tyvärr, det gick inte.", "already_on": "{name} är redan på.", "already_off": "{name} är redan av.", + "done": "Klart.", "preview_alone": "Skickas som en egen förfrågan.", "preview_grouped": "Skickas i en förfrågan tillsammans med: {others}.", "preview_cost": "Frågade en gång för den här förhandsgranskningen: {tokens} indatatoken, ungefär ${cost}, {ms} ms.", diff --git a/custom_components/jev/translations/zh-Hans.json b/custom_components/jev/translations/zh-Hans.json index 85f813c..bc7c05f 100644 --- a/custom_components/jev/translations/zh-Hans.json +++ b/custom_components/jev/translations/zh-Hans.json @@ -350,6 +350,7 @@ "intent_failed": "抱歉,没有成功。", "already_on": "{name} 已经开着了。", "already_off": "{name} 已经关着了。", + "done": "好的,完成了。", "preview_alone": "作为单独的请求发送。", "preview_grouped": "与以下问题合并在一个请求中发送:{others}。", "preview_cost": "为本次预览提问一次:{tokens} 个输入令牌,约 ${cost},{ms} 毫秒。", diff --git a/site-docs/conversation.md b/site-docs/conversation.md index 0279310..5f62a44 100644 --- a/site-docs/conversation.md +++ b/site-docs/conversation.md @@ -41,6 +41,11 @@ A room command always carries the kinds of device the model was shown. Home Assistant otherwise acts on every exposed entity in the room, so "turn off the hallway" would reach a lock exposed there and unlock it. +After an action it says the sentence Home Assistant's own agent says for the same +command, in the pipeline's language, such as "Turned on the light". Those sentences +come from Home Assistant's translations. Where they have none, as for a toggle, it +says "Done." + ## What it refuses | Case | What happens | diff --git a/tests/test_conversation.py b/tests/test_conversation.py index a684f01..5c660fa 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -1256,3 +1256,81 @@ async def test_a_room_past_the_entity_cap_is_not_offered(hass, config_entry): assert [e.entity_id for e in snapshot.entities] == ["light.a", "light.b"] assert snapshot.areas == ["Attic"] + + +_ENTITY = {} +_AREA = { + "target_type": ChoiceAnswer(choice="area", probabilities={}, confidence=0.94), + "entity": ChoiceAnswer(choice="none_of_these", probabilities={}, confidence=0.9), + "area": ChoiceAnswer(choice="Office", probabilities={}, confidence=0.93), +} +_ALL = { + "target_type": ChoiceAnswer(choice="everything", probabilities={}, confidence=0.95), + "entity": ChoiceAnswer(choice="none_of_these", probabilities={}, confidence=0.9), +} + + +@pytest.mark.parametrize( + ("language", "service", "text", "answers"), + [ + ("en", "turn_on", "turn on the kitchen light", _ENTITY), + ("en", "turn_off", "turn off the lights in the office", _AREA), + ("en", "turn_off", "turn off all the lights", _ALL), + ("nl", "turn_on", "zet de kitchen light aan", _ENTITY), + ("nl", "turn_off", "zet de lampen in de office uit", _AREA), + ("de", "turn_on", "schalte kitchen light ein", _ENTITY), + ("pl", "turn_off", "wyłącz światła w office", _AREA), + ], +) +async def test_an_action_says_what_the_default_agent_says( + hass, house, mock_client, language, service, text, answers +): + """An action used to reply with no sentence, and the Assist dialog showed nothing. + + The reference is the default agent itself, on a sentence it matches, for the + same command. + """ + action = ChoiceAnswer(choice=service, probabilities={}, confidence=0.98) + mock_client.ask.return_value = build_response(**answer_set(action=action, **answers)) + hass.services.async_register("light", service, lambda call: None) + + ours = await converse(hass, text, language=language) + theirs = await converse( + hass, text, agent_id="conversation.home_assistant", language=language + ) + + spoken = ours.response.speech["plain"]["speech"] + assert spoken + assert spoken == theirs.response.speech["plain"]["speech"] + + +@pytest.mark.parametrize(("language", "expected"), [("en", "Done."), ("nl", "Gedaan.")]) +async def test_an_action_with_no_sentence_of_its_own_says_done( + hass, house, mock_client, language, expected +): + """home-assistant-intents writes nothing for a toggle.""" + mock_client.ask.return_value = build_response( + **answer_set( + action=ChoiceAnswer(choice="toggle", probabilities={}, confidence=0.92) + ) + ) + hass.services.async_register("light", "toggle", lambda call: None) + + result = await converse(hass, "flip the kitchen light", language=language) + + assert result.response.speech["plain"]["speech"] == expected + + +async def test_a_brightness_command_says_it_was_set(hass, house, mock_client): + mock_client.ask.return_value = build_response( + **answer_set( + action=ChoiceAnswer( + choice="set_brightness", probabilities={}, confidence=0.95 + ) + ) + ) + hass.services.async_register("light", "turn_on", lambda call: None) + + result = await converse(hass, "set the kitchen light to 40%") + + assert result.response.speech["plain"]["speech"] == "Brightness set"