From 84b15ec724c12e5cc53a457bb611768856b6bc30 Mon Sep 17 00:00:00 2001 From: kai392 Date: Thu, 30 Jul 2026 23:18:53 +0800 Subject: [PATCH 1/3] fix(quick-router): abstain on absolute setpoint schedules and conditions Closes #914 Relative "in " setpoints already abstained (#829), but absolute schedules ("at 9pm", "tonight"), conditionals ("when I get home"), and exclusions ("except the bedroom") still actuated now and dropped the qualifier. Mirror the turn_on/turn_off multi-clause abstain so the LLM can arm them, while keeping bare setpoints and "at"-as-value-separator. Co-authored-by: Cursor --- crates/genie-core/src/tools/quick.rs | 146 +++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/genie-core/src/tools/quick.rs b/crates/genie-core/src/tools/quick.rs index c639e45f..30f682c3 100644 --- a/crates/genie-core/src/tools/quick.rs +++ b/crates/genie-core/src/tools/quick.rs @@ -1984,6 +1984,14 @@ fn home_control_request(text: &str) -> Option<(String, &'static str, Option return None; } } + // Absolute schedules ("at 9pm", "tonight"), conditionals ("when I get + // home"), and exclusions ("except the bedroom") must also abstain — the + // relative "in " guard above misses them, so the setpoint + // path used to actuate now and drop the qualifier (#914). Mirrors the + // multi-clause / schedule abstain on simple_turn_request. + if setpoint_has_schedule_or_condition(rest) { + return None; + } if let Some((entity, value)) = parse_temperature_target(rest) { // The action for a numeric setpoint depends on the device. A light // dims (set_brightness, #813); a thermostat/oven/heater sets @@ -2004,6 +2012,81 @@ fn home_control_request(text: &str) -> Option<(String, &'static str, Option None } +/// True when a `set`/`preheat` remainder carries an absolute schedule, +/// conditional, or exclusion that the quick-router must not actuate now. +/// +/// Distinguishes `"set the thermostat to 68 at 9pm"` (schedule → abstain) from +/// `"set the thermostat at 68"` (`at` is the value separator → keep). +fn setpoint_has_schedule_or_condition(rest: &str) -> bool { + let scoped = format!(" {rest} "); + if scoped.contains(" everything ") + || scoped.contains(" except ") + || scoped.contains(" only ") + || scoped.contains(" when ") + || scoped.contains(" unless ") + || scoped.contains(" if ") + || scoped.contains(" and ") + { + return true; + } + + // Trailing calendar / bedtime words that are not a room after "in the …". + if rest.ends_with(" tonight") + || rest.ends_with(" tomorrow") + || rest.ends_with(" today") + || rest.ends_with(" before bed") + || rest.ends_with(" before bedtime") + || rest.ends_with(" at bedtime") + || rest.ends_with(" at night") + || rest.ends_with(" at noon") + || rest.ends_with(" at midnight") + || rest.ends_with(" at midday") + { + return true; + } + + // "to at " — once `to` already introduced the setpoint, + // a later `at` is a clock/time qualifier, not the value separator used by + // `parse_temperature_target` for `"set the thermostat at 68"`. + if let Some((_, after_to)) = rest.split_once(" to ") + && let Some((_, at_tail)) = after_to.rsplit_once(" at ") + { + let at_tail = at_tail.trim(); + if is_absolute_schedule_tail(at_tail) { + return true; + } + } + + false +} + +fn is_absolute_schedule_tail(tail: &str) -> bool { + if tail.is_empty() { + return false; + } + if is_time_expression(tail) + || matches!( + tail, + "bedtime" | "night" | "noon" | "midnight" | "midday" | "tonight" | "tomorrow" | "today" + ) + { + return true; + } + // Clock times: "9pm", "9 pm", "9:30", "9:30pm". + let compact: String = tail + .chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + if compact.ends_with("am") || compact.ends_with("pm") { + return true; + } + if compact.contains(':') { + return true; + } + false +} + fn simple_turn_request(text: &str) -> Option<(String, &'static str)> { let (rest, action) = text .strip_prefix("turn on ") @@ -6346,6 +6429,69 @@ mod tests { assert_eq!(call.arguments["value"], 68); } + #[test] + fn setpoint_with_absolute_schedule_or_condition_abstains() { + // Relative "in " was already guarded (#829). Absolute schedules, + // conditionals, and exclusions still actuated now and dropped the + // qualifier (#914). Abstain so the LLM can arm them — same resolution as + // turn_on/turn_off. + for utterance in [ + "set the thermostat to 68 at 9pm", + "set the thermostat to 68 at bedtime", + "set the thermostat to 68 tonight", + "set the thermostat to 68 tomorrow", + "set the thermostat to 68 before bed", + "set the lights to 30 percent at 9pm", + "set the thermostat to 68 when I get home", + "set the thermostat to 68 unless it is cold", + "set the thermostat to 68 only at night", + "set the lights to 40 percent except the bedroom", + ] { + assert!(route(utterance).is_none(), "{utterance:?}"); + } + + // Unqualified setpoints — including `at` as the value separator and a + // trailing room after "in the …" — must still actuate now. + for (utterance, entity, action, value) in [ + ( + "set the thermostat to 68", + "thermostat", + "set_temperature", + 68, + ), + ( + "set the thermostat at 68", + "thermostat", + "set_temperature", + 68, + ), + ( + "set the thermostat to 68 in the den", + "thermostat", + "set_temperature", + 68, + ), + ( + "set the oven to 400 degrees", + "oven", + "set_temperature", + 400, + ), + ( + "set the lights to 40 percent", + "lights", + "set_brightness", + 40, + ), + ] { + let call = route(utterance).unwrap_or_else(|| panic!("no route for {utterance:?}")); + assert_eq!(call.name, "home_control", "{utterance:?}"); + assert_eq!(call.arguments["entity"], entity, "{utterance:?}"); + assert_eq!(call.arguments["action"], action, "{utterance:?}"); + assert_eq!(call.arguments["value"], value, "{utterance:?}"); + } + } + #[test] fn whats_contraction_matches_spelled_out_status_prefix() { // `normalize` folds "what's" -> "what s", so the status prefix strip left From 6fafa49e442ea4c8c9c35914acbf6c659ab221ca Mon Sep 17 00:00:00 2001 From: kai392 Date: Thu, 30 Jul 2026 23:23:50 +0800 Subject: [PATCH 2/3] fix: compare brightness setpoint JSON as f64 in schedule abstain test set_brightness keeps a float value argument; assert against json!(40.0). Co-authored-by: Cursor --- crates/genie-core/src/tools/quick.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/genie-core/src/tools/quick.rs b/crates/genie-core/src/tools/quick.rs index 30f682c3..6aca6082 100644 --- a/crates/genie-core/src/tools/quick.rs +++ b/crates/genie-core/src/tools/quick.rs @@ -6457,31 +6457,31 @@ mod tests { "set the thermostat to 68", "thermostat", "set_temperature", - 68, + serde_json::json!(68), ), ( "set the thermostat at 68", "thermostat", "set_temperature", - 68, + serde_json::json!(68), ), ( "set the thermostat to 68 in the den", "thermostat", "set_temperature", - 68, + serde_json::json!(68), ), ( "set the oven to 400 degrees", "oven", "set_temperature", - 400, + serde_json::json!(400), ), ( "set the lights to 40 percent", "lights", "set_brightness", - 40, + serde_json::json!(40.0), ), ] { let call = route(utterance).unwrap_or_else(|| panic!("no route for {utterance:?}")); From d4c8505cb545af5b5d0323a9b141b16accde21e4 Mon Sep 17 00:00:00 2001 From: kai392 Date: Thu, 30 Jul 2026 23:28:15 +0800 Subject: [PATCH 3/3] fix: do not treat number-word and as a setpoint condition Spoken amounts like "one hundred and five" contain " and "; abstaining on that token broke quick_spoken_setpoint_test. Keep when/unless/if/except/ only/everything without the coordinating "and" guard. Co-authored-by: Cursor --- crates/genie-core/src/tools/quick.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/genie-core/src/tools/quick.rs b/crates/genie-core/src/tools/quick.rs index 6aca6082..f0a37e66 100644 --- a/crates/genie-core/src/tools/quick.rs +++ b/crates/genie-core/src/tools/quick.rs @@ -2025,7 +2025,6 @@ fn setpoint_has_schedule_or_condition(rest: &str) -> bool { || scoped.contains(" when ") || scoped.contains(" unless ") || scoped.contains(" if ") - || scoped.contains(" and ") { return true; }