Skip to content
Merged
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
115 changes: 114 additions & 1 deletion crates/genie-core/src/tools/quick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2461,7 +2461,39 @@ fn home_status_target(text: &str) -> Option<String> {
return Some("self-cleaning oven".into());
}

if (text.contains("oven on") || text.contains("leave the oven"))
// The oven branch keyed on the bare phrase "oven on" / "leave the oven"
// with no question shape and no word boundary, so it fired on anything that
// merely contained the letters:
//
// * A *command* was answered with a status read. "turn the oven on" and
// "remind me to turn the oven on at six" both returned
// home_status{entity:"oven"}, so the oven never actuated. Every other
// device already abstains on this word order and lets the LLM ground
// the write — "turn the lights on" and "turn the fan on" both fall
// through — so the oven was the one appliance a trailing-particle
// command silently failed on.
// * A command or hypothetical *embedded* in a question ("what happens if
// i turn the oven on", "check if i should turn the oven on") is
// question-shaped without asking about the oven's state, so a generic
// `looks_like_status_query` gate does not separate it either.
// * "oven" matched inside "pr[oven]" / "w[oven]" / "leave the [oven]ware",
// so "is that theory proven on the test bench" reported the oven.
//
// Admit only the two shapes that actually ask about the oven's state, every
// token matched whole: the copular reading where the oven is the subject
// ("is/are the oven ... on") and the "did ... leave the oven ..."
// past-action check that the standard status-query prefixes do not cover.
// Anchoring on the subject rather than on a bare "is "/"are " prefix is what
// keeps the embedded-command forms out ("are you going to turn the oven on").
let words = text.split_whitespace().collect::<Vec<_>>();
let asks_oven_state = matches!(words.first().copied(), Some("is" | "are"))
&& words.get(1..3) == Some(&["the", "oven"][..])
&& words.get(3..).is_some_and(|rest| rest.contains(&"on"));
let asks_oven_left_on = words.first().copied() == Some("did")
&& words
.windows(3)
.any(|triple| triple == ["leave", "the", "oven"]);
if (asks_oven_state || asks_oven_left_on)
&& !text.contains("self cleaning")
&& !text.contains("self clean")
{
Expand Down Expand Up @@ -5926,6 +5958,87 @@ mod tests {
}
}

#[test]
fn turning_the_oven_on_is_a_command_not_a_status_read() {
// The "oven on" branch had no question-shape gate, so a command that
// merely contained the phrase was answered with a status report and the
// oven never actuated. Every other device already abstains on this word
// order (asserted below), which is what made the oven the odd one out.
for utterance in [
"Turn the oven on",
"Switch the oven on",
"Please turn the oven on",
"Can you turn the oven on?",
"Remind me to turn the oven on at six",
] {
assert!(
route(utterance).is_none(),
"{utterance:?} must abstain so the LLM can actuate the oven"
);
}

// The reference behavior the oven was diverging from.
for utterance in ["Turn the lights on", "Turn the fan on"] {
assert!(route(utterance).is_none(), "{utterance:?}");
}

// "oven" as a substring of "pr[oven]" / "w[oven]" / "leave the
// [oven]ware" must not report the oven either, even though all three
// utterances are status-shaped.
for utterance in [
"Is that theory proven on the test bench?",
"Is the basket woven on a loom?",
"Did I leave the ovenware on?",
] {
assert!(
route(utterance).is_none(),
"{utterance:?} must abstain and reach the LLM"
);
}

// A command or hypothetical embedded in a question is question-shaped
// without asking about the oven's state, so this branch must not claim
// it. These currently fall through to the cooktop branch and report
// `stove` — a separate pre-existing gap in that branch (same class as
// #861), so the assertion is scoped to what this branch owns.
for utterance in [
"What happens if I turn the oven on?",
"Check if I should turn the oven on",
"Are you going to turn the oven on?",
"What happens if I leave the oven on?",
] {
assert_ne!(
route(utterance)
.as_ref()
.and_then(|call| call.arguments.get("entity"))
.and_then(|entity| entity.as_str()),
Some("oven"),
"{utterance:?} must not resolve to the oven status entity"
);
}

// Genuine oven status questions still resolve, including the "did ..."
// form the standard gate prefixes do not cover.
for utterance in [
"Is the oven on?",
"Is the oven still on?",
"Did I leave the oven on?",
"Did you leave the oven on?",
] {
let call = route(utterance).unwrap_or_else(|| panic!("no route for {utterance:?}"));
assert_eq!(call.name, "home_status", "{utterance:?}");
assert_eq!(call.arguments["entity"], "oven", "{utterance:?}");
}

// The self-cleaning cycle keeps its own entity, and a setpoint keeps
// actuating through home_control.
let call = route("Is the self-cleaning oven on?").expect("self-cleaning oven route");
assert_eq!(call.arguments["entity"], "self-cleaning oven");
let call = route("Set the oven to 400 degrees").expect("oven setpoint route");
assert_eq!(call.name, "home_control");
assert_eq!(call.arguments["action"], "set_temperature");
}

#[test]
fn cover_and_gate_status_match_whole_words_not_substrings() {
// The cover/blinds branch matched its tokens with a substring
Expand Down
Loading