Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 136 additions & 3 deletions custom_components/jev/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
},
)


Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@
"intent_failed": "Извините, не получилось.",
"already_on": "{name} уже включён.",
"already_off": "{name} уже выключен.",
"done": "Готово.",
"preview_alone": "Отправляется отдельным запросом.",
"preview_grouped": "Отправляется одним запросом вместе с: {others}.",
"preview_cost": "Один вопрос для этого предпросмотра: {tokens} входных токенов, примерно ${cost}, {ms} мс.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions custom_components/jev/translations/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@
"intent_failed": "抱歉,没有成功。",
"already_on": "{name} 已经开着了。",
"already_off": "{name} 已经关着了。",
"done": "好的,完成了。",
"preview_alone": "作为单独的请求发送。",
"preview_grouped": "与以下问题合并在一个请求中发送:{others}。",
"preview_cost": "为本次预览提问一次:{tokens} 个输入令牌,约 ${cost},{ms} 毫秒。",
Expand Down
5 changes: 5 additions & 0 deletions site-docs/conversation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
78 changes: 78 additions & 0 deletions tests/test_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---


Expand Down
Loading