Skip to content
Open
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
145 changes: 145 additions & 0 deletions crates/genie-core/src/tools/quick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1984,6 +1984,14 @@ fn home_control_request(text: &str) -> Option<(String, &'static str, Option<f64>
return None;
}
}
// Absolute schedules ("at 9pm", "tonight"), conditionals ("when I get
// home"), and exclusions ("except the bedroom") must also abstain — the
// relative "in <duration>" 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
Expand All @@ -2004,6 +2012,80 @@ fn home_control_request(text: &str) -> Option<(String, &'static str, Option<f64>
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 ")
{
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 <value> at <schedule>" — 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;
Comment on lines +2050 to +2055

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle scheduled setpoints using at as the value separator.

set the thermostat at 68 at 9pm bypasses this to-only branch, then parse_temperature_target reads 68 and actuates immediately. Inspect the final at tail regardless of whether the numeric value was introduced by to or at, and add this regression case.

Proposed fix
-    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;
-        }
+    if let Some((before_at, at_tail)) = rest.rsplit_once(" at ")
+        && parse_temperature_target(before_at).is_some()
+        && is_absolute_schedule_tail(at_tail.trim())
+    {
+        return true;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
if let Some((before_at, at_tail)) = rest.rsplit_once(" at ")
&& parse_temperature_target(before_at).is_some()
&& is_absolute_schedule_tail(at_tail.trim())
{
return true;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/genie-core/src/tools/quick.rs` around lines 2051 - 2056, Update the
scheduled-setpoint detection around is_absolute_schedule_tail to inspect the
final “ at ” tail whether the numeric value follows “to” or “at”, so inputs like
“set the thermostat at 68 at 9pm” are recognized as scheduled rather than
actuated immediately. Add a regression test covering this “at”-separator form.

}
}

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::<String>()
.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 ")
Expand Down Expand Up @@ -6346,6 +6428,69 @@ mod tests {
assert_eq!(call.arguments["value"], 68);
}

#[test]
fn setpoint_with_absolute_schedule_or_condition_abstains() {
// Relative "in <duration>" 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",
serde_json::json!(68),
),
(
"set the thermostat at 68",
"thermostat",
"set_temperature",
serde_json::json!(68),
),
(
"set the thermostat to 68 in the den",
"thermostat",
"set_temperature",
serde_json::json!(68),
),
(
"set the oven to 400 degrees",
"oven",
"set_temperature",
serde_json::json!(400),
),
(
"set the lights to 40 percent",
"lights",
"set_brightness",
serde_json::json!(40.0),
),
] {
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
Expand Down
Loading