From 11846bad66116acb3b3ae4f2f509fca8ec182f3b Mon Sep 17 00:00:00 2001 From: AboveColin Date: Thu, 24 Sep 2026 08:17:32 +0200 Subject: [PATCH] A hidden device named in full is not swapped for an exposed one With an unexposed "Desk lamp" and an exposed "Lamp", "turn on the desk lamp" came back from the model as Lamp and turned it on, 3 of 3 times on a development instance. The model can only pick from what it was shown, and Lamp was the closest option. The snapshot now keeps the names of entities the model is not shown: not exposed, a lock or entrance cover, or past the cap. They stay in Home Assistant. When the command says one of them in more words than it says the chosen device, the agent falls back and records "named a device that is not exposed". --- custom_components/jev/interpret.py | 18 ++++++++++ custom_components/jev/snapshot.py | 20 ++++++++--- site-docs/conversation.md | 1 + tests/test_conversation.py | 55 ++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/custom_components/jev/interpret.py b/custom_components/jev/interpret.py index 9027d30..3d41bdb 100644 --- a/custom_components/jev/interpret.py +++ b/custom_components/jev/interpret.py @@ -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): @@ -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 @@ -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. diff --git a/custom_components/jev/snapshot.py b/custom_components/jev/snapshot.py index 6efdf62..4f28280 100644 --- a/custom_components/jev/snapshot.py +++ b/custom_components/jev/snapshot.py @@ -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]: @@ -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 @@ -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. @@ -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, ) diff --git a/site-docs/conversation.md b/site-docs/conversation.md index 0279310..b14103f 100644 --- a/site-docs/conversation.md +++ b/site-docs/conversation.md @@ -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 | diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 2c5ba3d..8a1f83f 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -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( @@ -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 --- @@ -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")