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
15 changes: 14 additions & 1 deletion custom_components/jev/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
193 changes: 171 additions & 22 deletions custom_components/jev/interpret.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -57,13 +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",
# 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
Expand All @@ -82,6 +186,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) + ")",
Expand All @@ -97,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)
Expand Down Expand Up @@ -158,14 +282,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(
Expand All @@ -186,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?",
{
Expand Down Expand Up @@ -267,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")
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
41 changes: 29 additions & 12 deletions site-docs/conversation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +27,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
Expand Down Expand Up @@ -53,12 +59,15 @@ 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" |
| 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"
Expand Down Expand Up @@ -102,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

Expand All @@ -112,15 +123,21 @@ 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
not a percentage.
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 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

Expand Down
3 changes: 2 additions & 1 deletion site-docs/cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading