use crate::knowledge::normalize_trigger;
use rust_stemmers::{Algorithm, Stemmer};
pub fn tokens(raw: &str, stem: bool) -> Vec<String> {
let Some(normalized) = normalize_trigger(raw) else {
return Vec::new();
};
let split = normalized
.split(|c: char| !c.is_alphanumeric())
.filter(|tok| !tok.is_empty());
if stem {
let stemmer = Stemmer::create(Algorithm::English);
split.map(|tok| stemmer.stem(tok).into_owned()).collect()
} else {
split.map(|tok| tok.to_string()).collect()
}
}
fn contains_contiguous(haystack: &[String], needle: &[String]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
pub fn match_triggers(message_tokens: &[String], triggers: &[String], stem: bool) -> Vec<String> {
triggers
.iter()
.filter(|trig| contains_contiguous(message_tokens, &tokens(trig, stem)))
.cloned()
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriggerMatch {
pub id: String,
pub triggers_matched: Vec<String>,
}
pub fn match_entries<'a, I>(message: &str, entries: I, stem: bool) -> Vec<TriggerMatch>
where
I: IntoIterator<Item = (&'a str, &'a [String])>,
{
let message_tokens = tokens(message, stem);
if message_tokens.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for (id, triggers) in entries {
let matched = match_triggers(&message_tokens, triggers, stem);
if !matched.is_empty() {
out.push(TriggerMatch {
id: id.to_string(),
triggers_matched: matched,
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn toks(s: &str) -> Vec<String> {
tokens(s, true)
}
fn bare(s: &str) -> Vec<String> {
tokens(s, false)
}
#[test]
fn word_boundary_ai_does_not_fire_on_said_or_maintain() {
let msg = toks("he said we should maintain it");
assert!(match_triggers(&msg, &["ai".to_string()], true).is_empty());
}
#[test]
fn word_boundary_ai_fires_as_whole_token() {
let msg = toks("the ai is helpful");
assert_eq!(
match_triggers(&msg, &["ai".to_string()], true),
vec!["ai".to_string()]
);
}
#[test]
fn stemming_diabetes_fires_on_diabetic() {
let msg = toks("he is diabetic");
assert_eq!(
match_triggers(&msg, &["diabetes".to_string()], true),
vec!["diabetes".to_string()]
);
}
#[test]
fn stemming_run_fires_on_running() {
let msg = toks("she is running today");
assert_eq!(
match_triggers(&msg, &["run".to_string()], true),
vec!["run".to_string()]
);
}
#[test]
fn no_stem_diabetes_does_not_fire_on_diabetic() {
let msg = bare("he is diabetic");
assert!(match_triggers(&msg, &["diabetes".to_string()], false).is_empty());
}
#[test]
fn no_stem_ayo_does_not_fire_on_tagalog_ayos() {
assert_eq!(
match_triggers(&toks("ayos lang"), &["ayo-".to_string()], true),
vec!["ayo-".to_string()],
"stemmed matching is exactly the false positive doors must avoid"
);
assert!(match_triggers(&bare("ayos lang"), &["ayo-".to_string()], false).is_empty());
}
#[test]
fn no_stem_hyphen_trigger_is_a_bare_token_prefix() {
assert_eq!(
match_triggers(
&bare("switched to ayo-mirage"),
&["ayo-".to_string()],
false
),
vec!["ayo-".to_string()]
);
assert_eq!(
match_triggers(&bare("AYO-LIGHT please"), &["ayo-".to_string()], false),
vec!["ayo-".to_string()]
);
}
#[test]
fn no_stem_gerf_does_not_fire_on_gerfalcon() {
assert!(
match_triggers(&bare("the gerfalcon stooped"), &["gerf".to_string()], false).is_empty()
);
assert_eq!(
match_triggers(&bare("ask gerf about it"), &["gerf".to_string()], false),
vec!["gerf".to_string()]
);
}
#[test]
fn phrase_fires_on_contiguous_in_order() {
let msg = toks("what is his blood sugar today");
assert_eq!(
match_triggers(&msg, &["blood sugar".to_string()], true),
vec!["blood sugar".to_string()]
);
}
#[test]
fn phrase_does_not_fire_out_of_order() {
let msg = toks("there is sugar in blood");
assert!(match_triggers(&msg, &["blood sugar".to_string()], true).is_empty());
}
#[test]
fn phrase_does_not_fire_when_not_contiguous() {
let msg = toks("blood pressure and high sugar");
assert!(match_triggers(&msg, &["blood sugar".to_string()], true).is_empty());
}
#[test]
fn no_stem_doubled_noun_phrase_needs_adjacency() {
let trig = vec!["garlic garlic".to_string()];
assert_eq!(
match_triggers(&bare("the garlic garlic is out"), &trig, false),
trig
);
assert!(match_triggers(&bare("garlic and more garlic"), &trig, false).is_empty());
assert_eq!(match_triggers(&bare("Garlic Garlic"), &trig, false), trig);
}
#[test]
fn nfc_precomposed_and_decomposed_cafe_match() {
let msg = toks("meet me at the cafe\u{0301} later");
assert_eq!(
match_triggers(&msg, &["caf\u{00e9}".to_string()], true),
vec!["caf\u{00e9}".to_string()]
);
}
#[test]
fn nfc_holds_with_stemming_off() {
let msg = bare("meet me at the cafe\u{0301} later");
assert_eq!(
match_triggers(&msg, &["caf\u{00e9}".to_string()], false),
vec!["caf\u{00e9}".to_string()]
);
}
#[test]
fn match_entries_returns_per_entry_matched_triggers() {
let brad = vec!["brad".to_string(), "blood sugar".to_string()];
let drew = vec!["drew".to_string()];
let entries: Vec<(&str, &[String])> =
vec![("kn-brad", brad.as_slice()), ("kn-drew", drew.as_slice())];
let matches = match_entries("can you check brad's blood sugar?", entries, true);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].id, "kn-brad");
assert_eq!(
matches[0].triggers_matched,
vec!["brad".to_string(), "blood sugar".to_string()]
);
}
#[test]
fn match_entries_empty_message_matches_nothing() {
let trig = vec!["brad".to_string()];
let entries: Vec<(&str, &[String])> = vec![("kn-brad", trig.as_slice())];
assert!(match_entries(" ", entries, true).is_empty());
}
}