From eef30472e3316dbae1b0d35dba9b3ed5316dbd66 Mon Sep 17 00:00:00 2001 From: michiot05 Date: Thu, 23 Jul 2026 04:58:22 +0200 Subject: [PATCH 1/2] fix(quick-router): match lock and door tokens as whole words in home_status home_status_target matched the lock/door group with a bare contains, so a common word that merely contains the token misrouted to home_status "locks" instead of abstaining: "is the clock on" (c[lock]) reported the door locks, and "out[door]" collided the same way. Mirrors the existing ice/iron/cooktop/cover whole-word fixes: match lock/locks/door/doors via split_whitespace().any(...). The multi-word "door lock"/"door locks" entries are redundant once the words match, and are dropped. --- crates/genie-core/src/tools/quick.rs | 47 +++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/genie-core/src/tools/quick.rs b/crates/genie-core/src/tools/quick.rs index 1c1d9a3a..e3c49200 100644 --- a/crates/genie-core/src/tools/quick.rs +++ b/crates/genie-core/src/tools/quick.rs @@ -2571,10 +2571,16 @@ fn home_status_target(text: &str) -> Option { }); } - if contains_any( - &target, - &["lock", "locks", "door lock", "door locks", "door"], - ) { + // Match the lock/door tokens as whole words, not substrings: a bare + // `contains_any` fired on "c[lock]" / "b[lock]" and "out[door]", so "is the + // clock on" misrouted to home_status "locks" instead of abstaining. Mirrors + // the ice/iron/cooktop/cover whole-word fixes above. The multi-word "door + // lock" / "door locks" entries are redundant once "lock"/"door" match as + // words, and are dropped. + if target + .split_whitespace() + .any(|word| matches!(word, "lock" | "locks" | "door" | "doors")) + { return Some(if target.split_whitespace().count() == 1 { "locks".into() } else { @@ -5550,6 +5556,39 @@ mod tests { } } + #[test] + fn lock_and_door_status_match_whole_words_not_substrings() { + // The lock/door branch matched its tokens with a substring `contains_any`, + // so "c[lock]" / "b[lock]" and "out[door]" misrouted to home_status + // "locks" (or a garbled multi-word entity) instead of abstaining. Mirrors + // the ice/iron/cooktop/cover whole-word fixes. + for utterance in [ + "is the clock on", + "is the wall clock right", + "is the block heater on", + ] { + assert!( + route(utterance) + .map(|c| c.arguments.get("entity").and_then(|e| e.as_str()) != Some("locks")) + .unwrap_or(true), + "{utterance:?} must not resolve to the locks status entity" + ); + } + + // Genuine lock/door queries still resolve: a bare token collapses to + // "locks", a named device keeps its full entity. + for (utterance, entity) in [ + ("are the doors locked", "locks"), + ("is the door locked", "locks"), + ("is the side door locked", "side door"), + ("is the garage door closed", "garage door"), + ] { + let call = route(utterance).unwrap_or_else(|| panic!("no route for {utterance:?}")); + assert_eq!(call.name, "home_status", "{utterance:?}"); + assert_eq!(call.arguments["entity"], entity, "{utterance:?}"); + } + } + #[test] fn control_entity_drops_leading_indefinite_article() { // clean_control_entity stripped a leading "the " but left "a"/"an", so From af8589d417fb242f411905e1dbd36a9889e3590c Mon Sep 17 00:00:00 2001 From: michiot05 Date: Thu, 23 Jul 2026 08:05:45 +0200 Subject: [PATCH 2/2] test(quick-router): assert full abstention on lock/door substring cases Strengthen the substring-collision assertions from "entity is not locks" to route().is_none(), so the test fails if any of these ever route through home_status with a different (garbled) entity. Add the "are the outdoor cameras on" case, which the substring door match misrouted to home_status "outdoor cameras". --- crates/genie-core/src/tools/quick.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/genie-core/src/tools/quick.rs b/crates/genie-core/src/tools/quick.rs index e3c49200..8a436026 100644 --- a/crates/genie-core/src/tools/quick.rs +++ b/crates/genie-core/src/tools/quick.rs @@ -5566,12 +5566,13 @@ mod tests { "is the clock on", "is the wall clock right", "is the block heater on", + // "out[door]" collided with the door token the same way — this + // misrouted to home_status "outdoor cameras" on the substring path. + "are the outdoor cameras on", ] { assert!( - route(utterance) - .map(|c| c.arguments.get("entity").and_then(|e| e.as_str()) != Some("locks")) - .unwrap_or(true), - "{utterance:?} must not resolve to the locks status entity" + route(utterance).is_none(), + "{utterance:?} must abstain from deterministic routing" ); }