Skip to content
Open
Changes from 1 commit
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
84 changes: 82 additions & 2 deletions crates/genie-core/src/tools/quick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1402,6 +1402,13 @@ fn preferred_timer_request(text: &str) -> Option<(u64, String)> {
}

fn priority_home_control_request(text: &str) -> Option<(String, &'static str, Option<f64>)> {
// A leading "please" is politeness, not part of the command. Most arms here
// are `contains` tests and survive it, but the two exact-match arms
// ("run movie night", "warm up the car") are anchored at token 0 and do not.
// Strip it so the polite form of those two routes like the bare form, and so
// this entry point agrees with home_control_request below.
let text = text.strip_prefix("please ").unwrap_or(text);

if text.contains("after dinner cleanup") || text.contains("after-dinner cleanup") {
return Some(("after-dinner cleanup".into(), "activate", None));
}
Expand Down Expand Up @@ -1894,6 +1901,19 @@ fn priority_home_control_request(text: &str) -> Option<(String, &'static str, Op
}

fn home_control_request(text: &str) -> Option<(String, &'static str, Option<f64>)> {
// A leading "please" is politeness, not part of the command. Every matcher
// below — the curated exact-match device set, the `starts_with` arms, the
// `set `/`preheat ` setpoint parser, and `simple_turn_request` — is anchored
// at token 0, so one politeness token shifted the anchor and the single most
// common polite actuation form ("please turn off the kitchen lights") fell
// through to the LLM even though its bare counterpart already routes.
// Mirrors scene_or_routine_activation_request and play_media_request, which
// strip it for the same reason; a trailing "please" is already dropped by
// clean_control_entity. Stripping here (rather than inside each arm) also
// keeps the schedule/multi-clause/known-device guards applying to the
// stripped text, so "please turn on the lights in 5 minutes" still abstains.
let text = text.strip_prefix("please ").unwrap_or(text);

if matches!(text, "turn off the tv" | "turn off tv") {
return Some(("tv".into(), "turn_off", None));
}
Expand Down Expand Up @@ -4760,8 +4780,12 @@ mod tests {
assert!(route("please help me").is_none());
// And a home_control-shaped polite request must not be claimed as a
// scene activation either (no "activate"/"start"/"run" … "scene"/
// "routine" shape).
assert!(route("please turn on the lights").is_none());
// "routine" shape). It falls past this matcher to the home_control path,
// which strips the same leading "please" and actuates the device.
let call = route("please turn on the lights").expect("no route");
assert_eq!(call.name, "home_control");
assert_eq!(call.arguments["entity"], "lights");
assert_eq!(call.arguments["action"], "turn_on");
}

#[test]
Expand Down Expand Up @@ -6256,6 +6280,62 @@ mod tests {
assert_eq!(call.arguments["action"], "turn_on");
}

#[test]
fn home_control_accepts_a_leading_please() {
// Every home_control matcher is anchored at token 0 — the curated
// exact-match set, the `starts_with` arms, the `set `/`preheat ` setpoint
// parser, and simple_turn_request's "turn on "/"turn off " prefixes. One
// leading politeness token shifted that anchor, so the most common polite
// actuation forms fell through to the LLM even though their bare
// counterparts already route. Same fix as scene/routine and play_media.
for (utterance, entity, action) in [
(
"please turn off the kitchen lights",
"kitchen lights",
"turn_off",
),
("please turn off the lights", "lights", "turn_off"),
("please turn on the bedroom lamp", "bedroom lamp", "turn_on"),
("please turn on the fan", "fan", "turn_on"),
("please turn off the tv", "tv", "turn_off"),
(
"please start the dishwasher",
"dishwasher normal cycle",
"activate",
),
] {
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:?}");
}

// Setpoints route through the same anchor and carry their value.
for (utterance, entity, value) in [
("please set the thermostat to 68", "thermostat", 68.0),
("please preheat the oven to 400", "oven", 400.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"], "set_temperature", "{utterance:?}");
assert_eq!(call.arguments["value"], value, "{utterance:?}");
}

// Politeness is stripped *before* the guards, not instead of them: the
// schedule, multi-clause, and unknown-device abstentions must all still
// fire on the polite form, otherwise stripping "please" would trade one
// wrong answer for another.
for utterance in [
"please turn on the lights in 5 minutes",
"please turn off everything downstairs except the kitchen lights",
"please turn on the porch light and the garage light",
"please turn on the infant monitor",
] {
assert!(route(utterance).is_none(), "{utterance:?}");
}
}

#[test]
fn turn_command_matches_fan_as_a_whole_word_not_a_substring() {
// The fan/fireplace gate used `entity.contains("fan")`, so a device whose
Expand Down
Loading