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
18 changes: 18 additions & 0 deletions custom_components/jev/interpret.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,12 @@ def pick(chosen: ExposedEntity, *, sure: bool) -> ExposedEntity | Interpretation
described = snapshot.by_id(entity.choice)
if described is None:
return out("named a device that is not exposed")
# The model picks the closest option it was shown, and a hidden device is
# not one of them. Measured on a development instance with an unexposed
# "Desk lamp" and an exposed "Lamp": "turn on the desk lamp" came back as
# Lamp and switched it on, three times of three.
if _a_hidden_name_fits_better(text, described, snapshot):
return out("named a device that is not exposed")
if ask_back:
picked = pick(described, sure=True)
if isinstance(picked, Interpretation):
Expand Down Expand Up @@ -379,6 +385,10 @@ def pick(chosen: ExposedEntity, *, sure: bool) -> ExposedEntity | Interpretation
and entity is not None
and (unsure := snapshot.by_id(_likeliest_device(entity))) is not None
):
# "Turn on the desk lamp" with a hidden desk lamp would otherwise ask
# which of two exposed lamps was meant.
if _a_hidden_name_fits_better(text, unsure, snapshot):
return out("named a device that is not exposed")
picked = pick(unsure, sure=False)
if isinstance(picked, Interpretation):
return picked
Expand Down Expand Up @@ -488,6 +498,14 @@ def spoken_name(one: ExposedEntity, other: ExposedEntity) -> tuple[str, str] | N
return None


def _a_hidden_name_fits_better(
text: str, chosen: ExposedEntity, snapshot: HomeSnapshot
) -> bool:
"""A hidden name that the command says in more words than the chosen one."""
said = _words_said(text, chosen.name)
return any(_words_said(text, name) > said for name in snapshot.hidden_names)


def _words_said(text: str, name: str) -> int:
"""How many words of the name the text says, as one phrase, or 0.

Expand Down
20 changes: 15 additions & 5 deletions custom_components/jev/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ class HomeSnapshot:
entities: list[ExposedEntity] = field(default_factory=list)
areas: list[str] = field(default_factory=list)
floors: list[str] = field(default_factory=list)
# Names of the entities the model is not shown: not exposed, left out above, or
# past the cap. They never leave Home Assistant. interpret() reads them so that
# "the desk lamp" cannot land on an exposed "Lamp" when the desk lamp is hidden.
hidden_names: list[str] = field(default_factory=list)

@property
def domains(self) -> list[str]:
Expand Down Expand Up @@ -140,13 +144,17 @@ def area_id_of(entity_id: str) -> str | None:
return None

found: list[ExposedEntity] = []
for state in hass.states.async_all(CONTROLLABLE):
if not async_should_expose(hass, CONVERSATION_DOMAIN, state.entity_id):
continue
hidden: list[str] = []
for state in hass.states.async_all():
if (
state.domain == "cover"
and state.attributes.get("device_class") in ENTRANCE_COVERS
state.domain not in CONTROLLABLE
or not async_should_expose(hass, CONVERSATION_DOMAIN, state.entity_id)
or (
state.domain == "cover"
and state.attributes.get("device_class") in ENTRANCE_COVERS
)
):
hidden.append(state.name)
continue
area_id = area_id_of(state.entity_id)
area = areas.async_get_area(area_id) if area_id else None
Expand All @@ -163,6 +171,7 @@ def area_id_of(entity_id: str) -> str | None:
# Sorted so the option list is stable between requests, which makes a trace
# readable when the same command is tried twice.
found.sort(key=lambda e: e.entity_id)
hidden.extend(e.name for e in found[limit:])
found = found[:limit]
# Counted after the cap, so a room that only had entities past the limit is not
# offered as somewhere the command could go.
Expand All @@ -186,4 +195,5 @@ def area_id_of(entity_id: str) -> str | None:
floors=sorted(
f.name for f in floors.async_list_floors() if f.floor_id in used_floor_ids
),
hidden_names=hidden,
)
1 change: 1 addition & 0 deletions site-docs/conversation.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ hallway" would reach a lock exposed there and unlock it.
| Needs words written or looked up | Fallback |
| 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 |
| A spent budget, a rejected key or no answer | Fallback. With no fallback agent it says which of the three it was |
Expand Down
55 changes: 55 additions & 0 deletions tests/test_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,39 @@ async def test_a_device_that_is_not_exposed_is_never_acted_on(hass, house, mock_
assert calls == []


@pytest.mark.parametrize(
("text", "acts"),
[
# The hidden name is said, in more words than the one the model picked.
("turn on the kitchen light strip", False),
("turn on the private light", False),
# Only the exposed name is said, or the hidden one is not said whole.
("turn on the kitchen light", True),
("turn on the kitchen lights", True),
],
)
async def test_a_hidden_device_named_in_full_is_not_swapped_for_an_exposed_one(
hass, house, mock_client, text, acts
):
"""Measured: an unexposed "Desk lamp" was said, and the exposed "Lamp" went on."""
hass.states.async_set("light.strip", "off", {"friendly_name": "Kitchen light strip"})
async_expose_entity(hass, conversation.DOMAIN, "light.strip", False)
mock_client.ask.return_value = build_response(**answer_set())
calls = []
hass.services.async_register("light", "turn_on", lambda call: calls.append(call))

await converse(hass, text)
await hass.async_block_till_done()

assert bool(calls) is acts
sent = mock_client.ask.call_args.args[0]
assert "light.strip" not in str(sent)
assert "Kitchen light strip" not in str(sent["entities"])
if not acts:
trace = house.runtime_data.conversation_traces[0]
assert trace["reason"] == "named a device that is not exposed"


async def test_brightness_is_read_from_the_text_not_the_model(hass, house, mock_client):
"""Jev judges and does not calculate, so the number comes out of a regex."""
mock_client.ask.return_value = build_response(
Expand Down Expand Up @@ -1370,6 +1403,8 @@ 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"]
# Past the cap is as good as hidden: the model never saw it to pick it.
assert snapshot.hidden_names == ["c"]


# --- asking which device ---
Expand Down Expand Up @@ -1465,6 +1500,26 @@ async def test_a_shared_name_is_asked_about_when_none_got_most_of_the_answer(
)


async def test_a_hidden_name_is_not_asked_about_as_a_shared_one(hass, house, mock_client):
rename(hass, "light.kitchen", "Lamp")
rename(hass, "light.office", "Lamp")
hass.states.async_set("light.desk", "off", {"friendly_name": "Desk lamp"})
async_expose_entity(hass, conversation.DOMAIN, "light.desk", False)
shares = {NONE: 0.55, "light.kitchen": 0.44, "light.office": 0.01}
mock_client.ask.return_value = build_response(
**answer_set(
entity=ChoiceAnswer(choice=NONE, probabilities=shares, confidence=0.55),
area=ChoiceAnswer(choice=NONE, probabilities={}, confidence=0.9),
)
)

result = await converse(hass, "turn on the desk lamp")

assert result.continue_conversation is False
trace = house.runtime_data.conversation_traces[0]
assert trace["reason"] == "named a device that is not exposed"


async def test_a_room_that_is_named_settles_a_shared_name(hass, house, mock_client):
rename(hass, "light.kitchen", "Lamp")
rename(hass, "light.office", "Lamp")
Expand Down
Loading