diff --git a/custom_components/jev/conversation.py b/custom_components/jev/conversation.py index 66f4f95..3863b87 100644 --- a/custom_components/jev/conversation.py +++ b/custom_components/jev/conversation.py @@ -64,7 +64,14 @@ ) from .coordinator import JevRuntimeData from .entity import build_device_info -from .interpret import NONE, Interpretation, build_questions, interpret, spoken_name +from .interpret import ( + ACTIONS, + NONE, + Interpretation, + build_questions, + interpret, + spoken_name, +) from .payload import payload_bytes from .snapshot import HomeSnapshot, async_heard_in, async_snapshot @@ -84,6 +91,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": ( "Not enough of the daily token budget is left for that, so I cannot do it today." @@ -460,12 +468,23 @@ async def _act( _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 ) @@ -609,6 +628,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 @@ -652,6 +674,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() + }, ) @@ -779,6 +811,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 225b8ed..01dfa77 100644 --- a/custom_components/jev/strings.json +++ b/custom_components/jev/strings.json @@ -355,6 +355,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 e6a0434..ca4b399 100644 --- a/custom_components/jev/translations/cs.json +++ b/custom_components/jev/translations/cs.json @@ -355,6 +355,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 cc46bdd..aa61a98 100644 --- a/custom_components/jev/translations/da.json +++ b/custom_components/jev/translations/da.json @@ -355,6 +355,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 2caff68..9d67186 100644 --- a/custom_components/jev/translations/de.json +++ b/custom_components/jev/translations/de.json @@ -355,6 +355,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 225b8ed..01dfa77 100644 --- a/custom_components/jev/translations/en.json +++ b/custom_components/jev/translations/en.json @@ -355,6 +355,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 cc33721..21296a9 100644 --- a/custom_components/jev/translations/es.json +++ b/custom_components/jev/translations/es.json @@ -355,6 +355,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 f19ba79..b62de08 100644 --- a/custom_components/jev/translations/fr.json +++ b/custom_components/jev/translations/fr.json @@ -355,6 +355,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 a5db6e8..7029509 100644 --- a/custom_components/jev/translations/it.json +++ b/custom_components/jev/translations/it.json @@ -355,6 +355,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 60c6d07..37a2210 100644 --- a/custom_components/jev/translations/nl.json +++ b/custom_components/jev/translations/nl.json @@ -355,6 +355,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 41a9893..da0906f 100644 --- a/custom_components/jev/translations/pl.json +++ b/custom_components/jev/translations/pl.json @@ -355,6 +355,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 3e09e6c..d6c2299 100644 --- a/custom_components/jev/translations/pt-BR.json +++ b/custom_components/jev/translations/pt-BR.json @@ -355,6 +355,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 ae78a40..e1cafc8 100644 --- a/custom_components/jev/translations/ru.json +++ b/custom_components/jev/translations/ru.json @@ -355,6 +355,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 9bac2b1..dca67c9 100644 --- a/custom_components/jev/translations/sv.json +++ b/custom_components/jev/translations/sv.json @@ -355,6 +355,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 17cfc9e..2d7150f 100644 --- a/custom_components/jev/translations/zh-Hans.json +++ b/custom_components/jev/translations/zh-Hans.json @@ -355,6 +355,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 b14103f..a62915c 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 8a1f83f..a1230ec 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -1407,6 +1407,84 @@ async def test_a_room_past_the_entity_cap_is_not_offered(hass, config_entry): assert snapshot.hidden_names == ["c"] +_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" + + # --- asking which device ---