From 226b878a8b5a2ee5dcb2bb52ea5d3c794b558851 Mon Sep 17 00:00:00 2001 From: AboveColin Date: Fri, 25 Sep 2026 06:10:19 +0200 Subject: [PATCH 1/2] Playback goes to the fallback agent, Hungarian brightness, and 1.16.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action criteria described turn_on as "start it" and turn_off as "stop it". On hosted Jev, "stop the music" came back turn_off at 0.98 and "play Metallica in the salon" turn_on at 0.43. A music player with no turn_off then failed the intent. The criteria now name playback under none_of_these, and six playback sentences came back none_of_these at 0.92 or more. An IntentHandleError goes to the fallback agent. Home Assistant raises it only when no target succeeded, so nothing changed. Any other intent error is now an error reply, so a satellite does not hear it as a command that went through. find_brightness reads "százalék" and a Hungarian level word, and treats "20%-kal" and the comparatives as a relative change. "vedd 20%-kal halványabbra" set the light to 20% before. --- custom_components/jev/conversation.py | 15 +++++- custom_components/jev/interpret.py | 21 +++++--- custom_components/jev/manifest.json | 2 +- site-docs/conversation.md | 18 ++++--- tests/test_conversation.py | 74 +++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 15 deletions(-) diff --git a/custom_components/jev/conversation.py b/custom_components/jev/conversation.py index 3863b87..7800932 100644 --- a/custom_components/jev/conversation.py +++ b/custom_components/jev/conversation.py @@ -464,9 +464,22 @@ async def _act( # miss, not a failure, so the fallback agent gets the sentence intact. _LOGGER.debug("intent %s matched nothing: %s", decision.intent_type, err) return await self._fall_back(user_input, "the named target was not found") + except ha_intent.IntentHandleError as err: + # Home Assistant raises this only when no entity succeeded, so nothing + # changed. A media player with no turn_off does this, and the fallback + # agent may know another way to do what was asked. + _LOGGER.debug("intent %s failed: %s", decision.intent_type, err) + return await self._fall_back( + user_input, "the intent failed on every target", "intent_failed" + ) except ha_intent.IntentError as err: _LOGGER.error("intent %s failed: %s", decision.intent_type, err) - return await self._speak(user_input, "intent_failed") + # An error, so a satellite does not hear it as a command that went through. + return await self._speak( + user_input, + "intent_failed", + error=ha_intent.IntentResponseErrorCode.FAILED_TO_HANDLE, + ) # Loading our lines reads translations, so only a reply without a sentence of # its own loads them. diff --git a/custom_components/jev/interpret.py b/custom_components/jev/interpret.py index 9bfec7f..6bee63e 100644 --- a/custom_components/jev/interpret.py +++ b/custom_components/jev/interpret.py @@ -35,8 +35,8 @@ } # The words that turn a number into a percentage, in the languages the integration -# is translated into. "%" carries most of the traffic; these are for a satellite -# that transcribes the word instead of the sign. +# is translated into, and in Hungarian. "%" carries most of the traffic; these are +# for a satellite that transcribes the word instead of the sign. _PERCENT_WORDS = ( "%", r"per ?cento?", # en, and it "per cento" @@ -46,6 +46,7 @@ "por ?ciento", # es "por ?cento", # pt-BR r"процент\w*", # ru + r"százalék\w*", # hu ) # The lookarounds keep a number whole: "1000 percent" and "12.5 percent" are not # brightnesses, and without them the regex found 0 and 5 inside them. @@ -61,7 +62,10 @@ # sets a level, so these go to the fallback agent rather than being read as 20%. _RELATIVE = re.compile( r"\b(?:brighter|dimmer|darker)\b" - r"|\b(?:by|met|um)\s+\d", + r"|\b(?:by|met|um)\s+\d" + # Hungarian puts "by" on the number as a suffix: "20%-kal", "20 százalékkal". + r"|\d\s*(?:%|százalék)-?(?:kal|kel)\b" + r"|\b(?:halványabb|világosabb|fényesebb|sötétebb)", re.IGNORECASE, ) @@ -82,6 +86,7 @@ "da": ("lys", "dæmp"), "cs": ("jas", "ztlum", "stmív"), "ru": ("ярк", "приглуш", "свет"), + "hu": ("fény", "halvány", "világos"), } _LEVEL = re.compile( r"\b(?:" + "|".join(s for g in _LEVEL_STEMS.values() for s in g) + ")", @@ -158,14 +163,16 @@ def build_questions( # No lock wording here on purpose. The agent does not control # locks, and Home Assistant's on/off convention for them runs the # opposite way round from speech. See CONTROLLABLE in snapshot.py. - "turn_on": "Switch something on, open it, start it, " - "or run a script or scene", - "turn_off": "Switch something off, close it, or stop it", + # No playback wording either. "Stop the music" is not a power + # command, and a player without turn_off fails it. + "turn_on": "Switch something on, open it, or run a script or scene", + "turn_off": "Switch something off or close it", "toggle": "Flip whatever state it is in now", "set_brightness": "Change how bright a light is", "get_state": "Answer a question about the current state, " "changing nothing", - NONE: "None of these, or the request is not about the house", + NONE: "None of these, such as playing, pausing, stopping or " + "skipping media, or the request is not about the house", }, ), "compound": Noul( diff --git a/custom_components/jev/manifest.json b/custom_components/jev/manifest.json index c8e9c3e..b0815bb 100644 --- a/custom_components/jev/manifest.json +++ b/custom_components/jev/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/AboveColin/HA-Jev/issues", "requirements": ["jevclient==1.2.0"], - "version": "1.16.0" + "version": "1.16.1" } diff --git a/site-docs/conversation.md b/site-docs/conversation.md index 4fa246e..69b436e 100644 --- a/site-docs/conversation.md +++ b/site-docs/conversation.md @@ -26,6 +26,11 @@ It runs Home Assistant's own intents: `HassTurnOn`, `HassTurnOff`, `HassToggle`, climate entities, vacuums, input booleans, scenes and scripts are turned on and off this way. Climate setpoints, and anything else, go to the fallback agent. +Playing, pausing, stopping and skipping media also go to the fallback agent. A +music player often has no `turn_off`, so "stop the music" read as turning it off +fails. With playback named as none of the above, a test of six playback sentences +all came back none of the above at 0.92 or more. + Locks are not on that list and are never described to the model. Home Assistant reads turn_on on a lock as `lock.lock` and turn_off as `lock.unlock`, the opposite way round from how the command is spoken, and a probability with no reasoning should not @@ -59,6 +64,7 @@ says "Done." | No room and no device named | Refused, unless you allow it. `turn_off` is exempt. Below the confidence floor, fallback | | Two kinds of device, no kind named, whole house | Asks which kind. With one kind exposed, it acts on that kind | | Two devices whose names fit the command equally well | Asks which one, see below | +| The device cannot do the action, such as a player with no `turn_off` | Fallback. Home Assistant reports this only when no device changed | | Too little budget left for the command, a rejected key or no answer | Fallback. With no fallback agent it says which of the three it was, as an error reply | !!! info "It only sees what Assist sees" @@ -112,14 +118,14 @@ asking the model. Jev judges and does not calculate, and a regex is exact and fr `40 percent`, `40%` and `40 procent` all work. `turn on 2 lamps` correctly yields no brightness. -The percent word is read in every language the integration is translated into, so -`40 Prozent`, `40 pour cent`, `40 per cento`, `40 por ciento`, `40 procent`, -`40 процентов` and `百分之40` all give 40. A bare number needs a word about light -level next to it, `dimme ... auf 30` or `ztlum ... na 30`, or it stays a count. +The percent word is read in every language the integration is translated into, and +in Hungarian, so `40 Prozent`, `40 pour cent`, `40 per cento`, `40 por ciento`, +`40 procent`, `40 процентов`, `40 százalékra` and `百分之40` all give 40. A bare +number needs a word about light level next to it, `dimme ... auf 30` or `ztlum ... na 30`, or it stays a count. With several numbers, the last one is the level: `dim bedroom 2 to 30` gives 30. -A relative change gives no brightness, so `20% brighter` and `dim it by 20` go to the -fallback agent rather than setting 20. A number over 100 or with a decimal point is +A relative change gives no brightness, so `20% brighter`, `dim it by 20` and +`20%-kal halványabbra` go to the fallback agent rather than setting 20. A number over 100 or with a decimal point is not a percentage. ## A command that is already done diff --git a/tests/test_conversation.py b/tests/test_conversation.py index a1230ec..5872fd8 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -14,6 +14,7 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import Context, ServiceCall +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar from homeassistant.helpers import chat_session from homeassistant.helpers import device_registry as dr @@ -313,6 +314,74 @@ async def test_a_free_text_request_goes_to_the_fallback_agent(hass, house, mock_ assert handovers[0].kwargs["agent_id"] == "conversation.home_assistant" +async def test_an_action_the_device_cannot_do_goes_to_the_fallback_agent( + hass, house, mock_client +): + """A Music Assistant player with no turn_off raised ServiceNotSupported on a real + instance, and the agent answered with an error. Home Assistant raises + IntentHandleError only when no target succeeded, so nothing has changed and the + fallback agent can try its own way. + """ + hass.config_entries.async_update_entry( + house, options={CONF_FALLBACK_AGENT: "conversation.home_assistant"} + ) + await hass.async_block_till_done() + mock_client.ask.return_value = build_response(**answer_set()) + + async def unsupported(call): + raise HomeAssistantError("Entity light.kitchen does not support this action") + + hass.services.async_register("light", "turn_on", unsupported) + + with patch( + "custom_components.jev.conversation.conversation.async_converse", + wraps=conversation.async_converse, + ) as handed_over: + await converse(hass, "kitchen light on") + + handovers = [ + c for c in handed_over.await_args_list if c.kwargs.get("agent_id") != AGENT + ] + assert len(handovers) == 1 + assert handovers[0].args[1] == "kitchen light on" + + +async def test_an_action_the_device_cannot_do_is_an_error_with_no_fallback( + hass, house, mock_client +): + """A satellite reads an action_done reply as a command that went through.""" + mock_client.ask.return_value = build_response(**answer_set()) + + async def unsupported(call): + raise HomeAssistantError("Entity light.kitchen does not support this action") + + hass.services.async_register("light", "turn_on", unsupported) + + result = await converse(hass, "kitchen light on") + + assert result.response.response_type is ha_intent.IntentResponseType.ERROR + assert ( + result.response.error_code is ha_intent.IntentResponseErrorCode.FAILED_TO_HANDLE + ) + assert result.response.speech["plain"]["speech"] == "Sorry, that did not work." + + +async def test_playback_is_not_described_as_switching_on_or_off(hass, house, mock_client): + """Measured on hosted Jev: with "or stop it" in turn_off, "stop the music" came + back turn_off at 0.98, and "play Metallica in the salon" turn_on at 0.43. With + playback named under none_of_these, all six playback sentences came back + none_of_these at 0.92 or more, and all six power commands kept their action. + """ + mock_client.ask.return_value = build_response(**answer_set()) + + await converse(hass, "stop the music") + + criteria = mock_client.ask.call_args.args[1]["action"].criteria + assert "stop" not in criteria["turn_off"] + assert "start" not in criteria["turn_on"] + assert "media" in criteria[NONE] + + async def test_the_fallback_never_points_at_itself(hass, house, mock_client): hass.config_entries.async_update_entry(house, options={CONF_FALLBACK_AGENT: AGENT}) await hass.async_block_till_done() @@ -657,6 +726,7 @@ def test_brightness_parsing(text, expected): ("saet lampen til 40 procent", 40), ("nastav lampu na 40 procent", 40), ("установи лампу на 40 процентов", 40), + ("kapcsold fel 40 százalékra", 40), # Chinese puts the marker in front of the number. ("把灯设为百分之40", 40), ("把灯设为 40%", 40), @@ -671,6 +741,7 @@ def test_brightness_parsing(text, expected): ("dæmp lampen til 30", 30), ("ztlum lampu na 30", 30), ("приглуши лампу до 30", 30), + ("állítsd a fényerejét 40-re", 40), ("把灯调暗到 30", 30), # Chinese writes no space in front of the number. ("把灯调暗到30", 30), @@ -1372,6 +1443,9 @@ async def test_two_devices_with_one_name_resolve_by_room(hass, house, mock_clien ("0.5%", None), ("make it 20% brighter", None), ("dim it by 20", None), + # Hungarian says "by" with a suffix on the number. This one set 20%. + ("vedd 20%-kal halványabbra", None), + ("vedd 20 százalékkal halványabbra", None), ], ) def test_a_brightness_is_only_read_when_it_is_a_level(text, expected): From b925a23db7555f92a3a798be362ac6584debf1ea Mon Sep 17 00:00:00 2001 From: AboveColin Date: Fri, 25 Sep 2026 06:23:38 +0200 Subject: [PATCH 2/2] Refuse timed and part-way commands, read "to" and "by" in brightness Two new questions stop a command that Home Assistant's intents cannot do as said. "turn off the lamp in 10 minutes" scored turn_off 0.97 and would have run now. "open the blinds halfway" scored turn_on 0.80 and would have opened them fully. On a live instance, 15 such sentences now refuse and 22 controls act or, for a lamp already on, say so. Brightness levels and state questions are among them. One question for both scored "set the lamp to 40 percent" at 0.64 and refused it, so they are two. They cost 127 input tokens. find_brightness reads the word before and after the number. A "to" makes it a level, a "by", a comparative or a change word with no "to" makes it an amount. 45 of 52 relative sentences in 14 languages set the amount as the level before, 0 now. Of 52 other sentences, most of them levels, 4 were wrong before and 0 now. --- custom_components/jev/interpret.py | 180 ++++++++++++++++++++++++++--- site-docs/conversation.md | 25 ++-- site-docs/cost.md | 3 +- tests/test_conversation.py | 89 +++++++++++++- 4 files changed, 267 insertions(+), 30 deletions(-) diff --git a/custom_components/jev/interpret.py b/custom_components/jev/interpret.py index 6bee63e..2555c9d 100644 --- a/custom_components/jev/interpret.py +++ b/custom_components/jev/interpret.py @@ -58,16 +58,116 @@ # the number and \b never fires between two characters that are both word # characters. "\u628a\u706f\u8c03\u6697\u523030" has to give 30. _BARE_NUMBER = re.compile(_NUMBER) -# "20% brighter" and "dim it by 20" change the level by an amount. HassLightSet only -# sets a level, so these go to the fallback agent rather than being read as 20%. -_RELATIVE = re.compile( - r"\b(?:brighter|dimmer|darker)\b" - r"|\b(?:by|met|um)\s+\d" - # Hungarian puts "by" on the number as a suffix: "20%-kal", "20 százalékkal". - r"|\d\s*(?:%|százalék)-?(?:kal|kel)\b" - r"|\b(?:halványabb|világosabb|fényesebb|sötétebb)", +# A number can be the level to set or the amount to change it by. HassLightSet only +# sets a level, so an amount goes to the fallback agent rather than being read as +# the level: "turn it up 20%" on a light at 60% set it to 20. When the words are +# unclear the number is not read, because a fallback costs a sentence and a wrong +# level turns the room dark. +# +# Words in front of the number that make it the level: "to 20%", "auf 20". +_TO = re.compile( + r"(?:\b(?:to|at|auf|zu|op|naar|tot|à|a|au|al|allo|alla|para|na|do|på|till|til|до)" + r"|到|为|成|至)\s*$", re.IGNORECASE, ) +# Words in front of the number that make it an amount: "by 20%", "um 20". Russian +# "на", Portuguese "em" and Spanish "en" mean both, so the change words below decide. +_BY = re.compile( + r"\b(?:by|um|met|de|del|di|un|o|med)\s*$", + re.IGNORECASE, +) +# Hungarian puts "to" and "by" on the number as a suffix: "20%-ra", "20%-kal". +_TO_SUFFIX = re.compile(r"^(?:\s*százalék)?-?(?:ra|re)\b", re.IGNORECASE) +_BY_SUFFIX = re.compile(r"^(?:\s*százalék)?-?(?:kal|kel)\b", re.IGNORECASE) +# Words anywhere in the sentence that ask for a change rather than a level. They +# count only when no "to" stands in front of the number, so "turn it up to 50%" is +# still 50. Stems, matched at a word start. +_CHANGE_STEMS = { + "en": ( + # "dim the lamp 20 percent" can mean either. "dim it to 20" is a level. + r"(?:increase|decrease|raise|lower|reduce|boost|add|brighten)", + r"dim\b", + r"(?:up|down|more|less|plus|minus|brighter|dimmer|darker)\b", + ), + "de": ( + "erhöh", + "verringer", + "reduzier", + "senk", + "heller", + "dunkler", + "mehr\b", + "weniger", + "plus\b", + "minus\b", + ), + "nl": ( + "verhoog", + "verlaag", + "feller", + "lichter", + "donkerder", + "meer\b", + "minder\b", + "min\b", + ), + "fr": ("augment", "baiss", "diminu", "rédui", "redui", "plus\b", "moins\b"), + "it": ("aument", "abbass", "diminu", "riduc", "più\b", "piu\b", "meno\b"), + "es": ("aument", "sube", "baja", "disminu", "reduc", "más\b", "menos\b"), + "pt-BR": ("aument", "diminu", "reduz", "mais\b", "menos\b"), + "pl": ( + "zwiększ", + "zmniejsz", + "podnieś", + "obniż", + "jaśniej", + "ciemniej", + "więcej", + "mniej", + ), + "sv": ("öka", "sänk", "minska", "ljusare", "mörkare", "mer\b", "mindre\b"), + "da": ("øg\b", "sænk", "lysere", "mørkere", "mere\b", "mindre\b"), + "cs": ("zvyš", "zvýš", "sniž", "jasněji", "tmavěji", "víc", "méně"), + "ru": ( + "увелич", + "уменьш", + "прибав", + "убав", + "повыс", + "пониз", + "ярче", + "темнее", + "больше", + "меньше", + ), + "hu": ("növel", "csökkent", "halványabb", "világosabb", "fényesebb", "sötétebb"), +} +_CHANGE = re.compile( + r"\b(?:" + "|".join(s for g in _CHANGE_STEMS.values() for s in g) + ")", + re.IGNORECASE, +) +# No spaces in Chinese, so a word boundary never fires in front of these. +_CHANGE_CJK = ( + "增加", + "减少", + "降低", + "提高", + "调亮", + "调暗", + "调高", + "调低", + "更亮", + "更暗", +) +# A comparative straight after the number is an amount even behind "to": the +# sentence says "20% brighter", not "to 20%". +_COMPARATIVE_AFTER = re.compile( + r"^\s*(?:%|" + "|".join(_PERCENT_WORDS[1:]) + r")?\s*(?:" + r"brighter|dimmer|darker|more|less|heller|dunkler|feller|lichter|donkerder" + r"|plus|ljusare|mörkare|lysere|mørkere|ярче|темнее|更亮|更暗)", + re.IGNORECASE, +) + # A bare number becomes a brightness only when the sentence also says something # about light level. The model already chose set_brightness by this point, so this @@ -102,22 +202,41 @@ def _in_range(raw: str) -> int | None: def find_brightness(text: str) -> int | None: - """A percentage in the text, if there is one. + """The level a sentence sets, if it says one. Prefers an explicit percent sign, because "turn on 2 lamps" holds a number that is not a brightness. Without one, the last number wins, because a device name - comes before its level: "lamp 2 brightness to 40" means 40. + comes before its level: "lamp 2 brightness to 40" means 40. A number that is an + amount to change the level by gives None. """ - if _RELATIVE.search(text): + found = ( + _PERCENT.search(text) + or _PERCENT_PREFIX.search(text) + or (_last_level_number(text)) + ) + if found is None or _is_an_amount(text, found): return None - if m := _PERCENT.search(text): - return _in_range(m.group(1)) - if m := _PERCENT_PREFIX.search(text): - return _in_range(m.group(1)) - if _LEVEL.search(text) or any(word in text for word in _LEVEL_CJK): - if numbers := _BARE_NUMBER.findall(text): - return _in_range(numbers[-1]) - return None + return _in_range(found.group(1)) + + +def _last_level_number(text: str) -> re.Match[str] | None: + if not (_LEVEL.search(text) or any(word in text for word in _LEVEL_CJK)): + return None + *_, last = (None, *_BARE_NUMBER.finditer(text)) + return last + + +def _is_an_amount(text: str, number: re.Match[str]) -> bool: + before, after = text[: number.start()], text[number.end() :] + # "百分之" sits in front of the number, so the words before it come before that. + before = before.removesuffix("百分之").rstrip() + if _COMPARATIVE_AFTER.search(after) or _BY_SUFFIX.search(after): + return True + if _TO.search(before) or _TO_SUFFIX.search(after): + return False + if _BY.search(before): + return True + return bool(_CHANGE.search(text)) or any(word in text for word in _CHANGE_CJK) @dataclass(slots=True) @@ -193,6 +312,25 @@ def build_questions( true="It needs text written, quoted or looked up", false="It is a device command or a question about device state", ), + # Home Assistant's own agent has no timer or condition for an on/off + # command, so "turn off the lamp in 10 minutes" would turn it off now. + # A brightness level is not a part-way position, so each gets its own + # question: one that asked about both scored "set the lamp to 40 + # percent" at 0.64 and refused it. + "later": Noul( + "Does the command say to do it at another time, for a set time, or " + "only if something happens?", + true="It gives a time, a delay, a duration or a condition", + false="It is to be done now", + ), + # turn_on opens a cover all the way, so "open the blinds halfway" read as + # turn_on opens them fully. + "part": Noul( + "Does the command ask to open or close something only part of the way?", + true="It asks for a position between open and closed", + false="It asks for fully open or closed, or it is not about opening " + "or closing", + ), "target_type": Choice( "How is the target named?", { @@ -274,6 +412,10 @@ def out(reason: str) -> Interpretation: return out("several commands in one sentence") if noul("free_text") >= 0.5: return out("needs text written or looked up") + if noul("later") >= 0.5: + return out("for another time or on a condition") + if noul("part") >= 0.5: + return out("a position part of the way") action = choice("action") entity = choice("entity") diff --git a/site-docs/conversation.md b/site-docs/conversation.md index 69b436e..0127295 100644 --- a/site-docs/conversation.md +++ b/site-docs/conversation.md @@ -7,11 +7,12 @@ Conversation agent to **Jev**. ## What it does -One sentence becomes one request carrying five to seven questions. Five are always -there: what should happen, is it compound, does it need text written, how is the -target named, which entity. Which room is added when you have rooms holding exposed +One sentence becomes one request carrying seven to nine questions. Seven are always +there: what should happen, is it compound, does it need text written, is it for +another time or on a condition, is it a position part of the way, how is the target +named, which entity. Which room is added when you have rooms holding exposed entities, and which kind of device when the exposed entities span two domains or -more. A one-domain house with no areas is asked five. +more. A one-domain house with no areas is asked seven. All but one or two of those answers are discarded on any given sentence. That is the cheap shape, not waste: three questions measured 712 ms and a hundred measured 714, so @@ -58,6 +59,8 @@ says "Done." | Below the confidence floor | The whole sentence goes to the fallback agent, nothing done first | | Two commands in one sentence | Fallback | | Needs words written or looked up | Fallback | +| For another time, for a set time or on a condition, such as "turn off the lamp in 10 minutes" | Fallback. Home Assistant's intents have no timer, so the command would run now | +| A cover part of the way, such as "open the blinds halfway" | Fallback. `turn_on` opens a cover all the way | | A lock or a garage, gate or door cover | Fallback. The agent never describes one | | An entity you did not expose to Assist | Never described to the model at all | | A hidden device named in full, next to an exposed one with a shorter name | Fallback. "Turn on the desk lamp" does not turn on "Lamp" | @@ -108,7 +111,9 @@ up a bill. Measured live: 257 to 455 ms warm, 512 to 753 ms on the first call after a restart, and 1,329 to 1,371 input tokens per command with five entities exposed. Thirty -commands came to $0.0017. +commands came to $0.0017. That was with seven questions. The two added in 1.16.1 +cost 127 more input tokens, 1,696 to 1,823 with twelve entities exposed, and the +same time warm: 261 ms before, 263 ms after. ## Brightness comes from a regex @@ -125,8 +130,14 @@ number needs a word about light level next to it, `dimme ... auf 30` or `ztlum . With several numbers, the last one is the level: `dim bedroom 2 to 30` gives 30. A relative change gives no brightness, so `20% brighter`, `dim it by 20` and -`20%-kal halványabbra` go to the fallback agent rather than setting 20. A number over 100 or with a decimal point is -not a percentage. +`20%-kal halványabbra` go to the fallback agent rather than setting 20. A word for +"to" in front of the number makes it a level, so `turn up the lamp to 80%`, +`verhoog de helderheid naar 80%` and `növeld a fényerőt 80%-ra` give 80. A word for +"by", or a word for changing with no "to", makes it an amount: `increase the +brightness by 20%`, `turn the lamp down 20%`, `erhöhe die Helligkeit um 20%` and +`把灯调亮20%` give none. In a test of 52 relative sentences in 14 languages, 45 set +the amount as the level before this rule and none do now. A number over 100 or with +a decimal point is not a percentage. ## A command that is already done diff --git a/site-docs/cost.md b/site-docs/cost.md index 3952e57..c3d50c1 100644 --- a/site-docs/cost.md +++ b/site-docs/cost.md @@ -34,7 +34,8 @@ grouping does for you automatically. ## Where the money actually goes For a small house, the **questions** dominate. A spoken command with 5 entities -exposed is about 1,350 tokens, of which the seven questions are most of it. +exposed was about 1,350 tokens with seven questions, which were most of it. The +agent asks nine since 1.16.1, 127 tokens more. For a large target, the **entities** dominate. At 150 entities you are paying roughly 110 tokens per entity per call. diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 5872fd8..a6c5fd0 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -46,6 +46,8 @@ def answer_set(**overrides): "action": ChoiceAnswer(choice="turn_on", probabilities={}, confidence=0.97), "compound": NoulAnswer(noul=0.02), "free_text": NoulAnswer(noul=0.01), + "later": NoulAnswer(noul=0.03), + "part": NoulAnswer(noul=0.04), "target_type": ChoiceAnswer(choice="entity", probabilities={}, confidence=0.9), "entity": ChoiceAnswer(choice="light.kitchen", probabilities={}, confidence=1.0), "area": ChoiceAnswer(choice="none_of_these", probabilities={}, confidence=0.4), @@ -149,9 +151,16 @@ async def test_every_question_goes_in_one_request(hass, house, mock_client): assert mock_client.ask.await_count == 1 questions = mock_client.ask.call_args.args[1] - assert {"action", "compound", "free_text", "target_type", "entity", "area"} <= set( - questions - ) + assert { + "action", + "compound", + "free_text", + "later", + "part", + "target_type", + "entity", + "area", + } <= set(questions) async def test_an_area_command_reaches_both_lights_in_that_area(hass, house, mock_client): @@ -208,6 +217,33 @@ async def test_a_compound_command_acts_on_nothing(hass, house, mock_client): assert result.response.error_code is ha_intent.IntentResponseErrorCode.NO_INTENT_MATCH +@pytest.mark.parametrize( + ("question", "text"), + [ + # Measured: turn_off 0.97 and turn_on 0.98 with nothing else to stop them. + ("later", "turn off the lamp in 10 minutes"), + ("later", "turn on the kitchen light when I get home"), + # turn_on opens a cover all the way. + ("part", "open the blinds halfway"), + ], +) +async def test_a_command_for_later_or_part_of_the_way_acts_on_nothing( + hass, house, mock_client, question, text +): + mock_client.ask.return_value = build_response( + **answer_set(**{question: NoulAnswer(noul=0.97)}) + ) + calls = [] + hass.services.async_register("light", "turn_on", lambda call: calls.append(call)) + hass.services.async_register("light", "turn_off", lambda call: calls.append(call)) + + result = await converse(hass, text) + await hass.async_block_till_done() + + assert calls == [] + assert result.response.error_code is ha_intent.IntentResponseErrorCode.NO_INTENT_MATCH + + async def test_low_confidence_acts_on_nothing(hass, house, mock_client): mock_client.ask.return_value = build_response( **answer_set( @@ -1453,6 +1489,53 @@ def test_a_brightness_is_only_read_when_it_is_a_level(text, expected): assert find_brightness(text) == expected +@pytest.mark.parametrize( + ("text", "expected"), + [ + # A change by an amount. Before, each of these set the amount as the level. + ("increase the brightness by 20%", None), + ("turn the lamp down 20%", None), + ("20% less", None), + ("brightness minus 20%", None), + ("dim the lamp 20 percent", None), + ("set the lamp to 20% brighter", None), + ("erhöhe die Helligkeit um 20%", None), + ("verhoog de helderheid met 20%", None), + ("augmente la luminosité de 20%", None), + ("abbassa la luce del 20 per cento", None), + ("sube el brillo un 20%", None), + ("aumente o brilho em 20%", None), + ("zwiększ jasność o 20%", None), + ("öka ljusstyrkan med 20%", None), + ("gør lampen 20% lysere", None), + ("zvyš jas o 20 %", None), + ("уменьши яркость на 20 процентов", None), + ("把灯调亮20%", None), + ("亮度降低百分之20", None), + ("növeld a fényerőt 20%-kal", None), + # A change word with "to" in front of the number is a level. + ("turn up the lamp to 80%", 80), + ("lower the lamp to 20%", 20), + ("Helligkeit auf 50 Prozent erhöhen", 50), + ("verhoog de helderheid naar 80%", 80), + ("augmente la luminosité à 80%", 80), + ("aumenta la luminosità al 80%", 80), + ("sube el brillo al 80%", 80), + ("aumente o brilho para 80%", 80), + ("zwiększ jasność do 80%", 80), + ("öka ljusstyrkan till 80%", 80), + ("øg lysstyrken til 80%", 80), + ("zvyš jas na 80 %", 80), + ("увеличь яркость до 80%", 80), + ("增加亮度到80%", 80), + ("növeld a fényerőt 80%-ra", 80), + ("legyen világosabb 30-ra", 30), + ], +) +def test_a_change_by_an_amount_is_not_a_level(text, expected): + assert find_brightness(text) == expected + + async def test_a_room_past_the_entity_cap_is_not_offered(hass, config_entry): from custom_components.jev.snapshot import async_snapshot