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")