From 6d910533534bfc920d257ec28492aa69e6d3f102 Mon Sep 17 00:00:00 2001 From: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com> Date: Wed, 22 Jul 2026 02:46:07 -0400 Subject: [PATCH] fix(quick-router): compose a digit with a following magnitude word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse_spoken_number`'s digit fast-path returned on the first integer token and never consumed a following "hundred"/"thousand", so a mixed digit + magnitude utterance — how STT commonly renders round numbers — was emitted as two tokens: - "100 thousand divided by 4" -> "100 1000 / 4" (should be "100000 / 4") - "20 thousand times 2" -> "20 1000 * 2" (should be "20000 * 2") - "5 hundred plus 10" -> "5 100 + 10" (should be "500 + 10") This is the same garble the all-word path already handles (`routes_compound_spoken_cardinals_to_calculate`: "one hundred / five" was once "1 100 / 5"); the digit + magnitude form still slipped through. - `parse_spoken_number` (`crates/genie-core/src/tools/number_words.rs`): in the digit fast-path, scale the value by an immediately following "hundred"/ "thousand" and consume that word, instead of returning the bare digit. A digit with no magnitude word after it still returns as-is, so "100 divided by 5" and "a 15 minute timer" (article "a" + digit) are unchanged. - Add `routes_digit_with_magnitude_word_to_calculate`. --- crates/genie-core/src/tools/number_words.rs | 11 ++++++++++- crates/genie-core/src/tools/quick.rs | 22 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/genie-core/src/tools/number_words.rs b/crates/genie-core/src/tools/number_words.rs index 8754c6a1..847bec56 100644 --- a/crates/genie-core/src/tools/number_words.rs +++ b/crates/genie-core/src/tools/number_words.rs @@ -8,7 +8,16 @@ enum CardinalWord { pub(crate) fn parse_spoken_number(tokens: &[&str], start: usize) -> Option<(u64, usize)> { if let Some(Ok(value)) = tokens.get(start).map(|token| token.parse::()) { - return Some((value, start + 1)); + // Compose a digit token with an immediately following magnitude word so + // "100 thousand" / "5 hundred" scale correctly. Without this the digit + // returned alone and the magnitude word was later mis-parsed as its own + // number ("100 thousand" -> "100 1000"). A digit with no magnitude word + // still returns as-is. + return Some(match tokens.get(start + 1) { + Some(&"hundred") => (value.saturating_mul(100), start + 2), + Some(&"thousand") => (value.saturating_mul(1000), start + 2), + _ => (value, start + 1), + }); } let mut total: u64 = 0; diff --git a/crates/genie-core/src/tools/quick.rs b/crates/genie-core/src/tools/quick.rs index f973799a..8fd50c5c 100644 --- a/crates/genie-core/src/tools/quick.rs +++ b/crates/genie-core/src/tools/quick.rs @@ -5790,6 +5790,28 @@ mod tests { assert_eq!(call.arguments["expression"], "100 / 5"); } + #[test] + fn routes_digit_with_magnitude_word_to_calculate() { + // Mixed digit + magnitude word ("100 thousand", "5 hundred") is how STT + // commonly renders round numbers. parse_spoken_number's digit fast-path + // returned on the digit and left the magnitude word behind, so "100 + // thousand / 4" became the garbled "100 1000 / 4" instead of "100000 / 4" + // — the same class as the all-word garble fixed above. + for (utterance, expression) in [ + ("what is 100 thousand divided by 4", "100000 / 4"), + ("what is 20 thousand times 2", "20000 * 2"), + ("what is 5 hundred plus 10", "500 + 10"), + ] { + let call = route(utterance).unwrap_or_else(|| panic!("no route for {utterance:?}")); + assert_eq!(call.name, "calculate", "{utterance:?}"); + assert_eq!(call.arguments["expression"], expression, "{utterance:?}"); + } + + // A bare digit not followed by a magnitude word is unchanged. + let call = route("what is 1000 divided by 4").unwrap(); + assert_eq!(call.arguments["expression"], "1000 / 4"); + } + #[test] fn routes_weather_and_home_status_before_memory_recall() { let call = route("Jared: Is it raining for school pickup?").unwrap();