search : recherche fuzzy + suggestions (issue #26) #43
+159
-23
@@ -80,35 +80,125 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Case-insensitive search over name, display name, description,
|
/// Case-insensitive search over name, display name, description,
|
||||||
/// category, tags and note.
|
/// category, tags and note — typo-tolerant, ranked by relevance.
|
||||||
pub fn search(&self, keyword: &str, category: Option<&str>) -> Vec<&AgentDef> {
|
pub fn search(&self, keyword: &str, category: Option<&str>) -> Vec<&AgentDef> {
|
||||||
let kw = keyword.to_lowercase();
|
let kw = keyword.to_lowercase();
|
||||||
self.agents
|
let mut scored: Vec<(u32, &AgentDef)> = self
|
||||||
|
.agents
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|a| {
|
.filter(|a| !a.hidden)
|
||||||
if a.hidden {
|
.filter(|a| category.map_or(true, |c| a.category.as_deref() == Some(c)))
|
||||||
return false;
|
.filter_map(|a| {
|
||||||
}
|
let name = a.name.to_lowercase();
|
||||||
if let Some(cat) = category {
|
let title = a.title().to_lowercase();
|
||||||
if a.category.as_deref() != Some(cat) {
|
let rest = format!(
|
||||||
return false;
|
"{} {} {}",
|
||||||
}
|
a.description.as_deref().unwrap_or("").to_lowercase(),
|
||||||
}
|
a.category.as_deref().unwrap_or("").to_lowercase(),
|
||||||
let hay = format!(
|
a.tags.join(" ").to_lowercase()
|
||||||
"{} {} {} {} {} {}",
|
);
|
||||||
a.name,
|
let score = score_keyword(&kw, &name)
|
||||||
a.title(),
|
.saturating_mul(3)
|
||||||
a.description.as_deref().unwrap_or(""),
|
.saturating_add(score_keyword(&kw, &title).saturating_mul(2))
|
||||||
a.category.as_deref().unwrap_or(""),
|
.saturating_add(score_keyword(&kw, &rest));
|
||||||
a.tags.join(" "),
|
(score > 0).then_some((score, a))
|
||||||
a.note.as_deref().unwrap_or("")
|
|
||||||
)
|
|
||||||
.to_lowercase();
|
|
||||||
hay.contains(&kw)
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
|
scored.sort_by(|(sa, a), (sb, b)| sb.cmp(sa).then(a.name.cmp(&b.name)));
|
||||||
|
scored.into_iter().map(|(_, a)| a).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Did you mean" candidates for a keyword with no results: closest
|
||||||
|
/// agent names and display names by edit distance.
|
||||||
|
pub fn suggest(&self, keyword: &str) -> Vec<String> {
|
||||||
|
let kw = keyword.to_lowercase();
|
||||||
|
let mut out: Vec<(usize, String)> = Vec::new();
|
||||||
|
for a in self.agents.iter().filter(|a| !a.hidden) {
|
||||||
|
for field in [&a.name, &a.title().to_string()] {
|
||||||
|
let f = field.to_lowercase();
|
||||||
|
if f.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let d = levenshtein(&kw, &f);
|
||||||
|
if d <= 3 && !out.iter().any(|(_, n)| n == &a.name) {
|
||||||
|
out.push((d, a.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|(da, na), (db, nb)| da.cmp(db).then(na.cmp(nb)));
|
||||||
|
out.truncate(4);
|
||||||
|
out.into_iter().map(|(_, n)| n).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score a keyword against one haystack field: 100 for a full hit,
|
||||||
|
/// then subsequence and typo bonuses; 0 = no relation at all.
|
||||||
|
fn score_keyword(keyword: &str, hay: &str) -> u32 {
|
||||||
|
if keyword.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let tokens: Vec<&str> = keyword.split_whitespace().collect();
|
||||||
|
let mut total = 0u32;
|
||||||
|
for token in &tokens {
|
||||||
|
if token.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let s = score_token(token, hay);
|
||||||
|
if s == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
total += s;
|
||||||
|
}
|
||||||
|
total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_token(token: &str, hay: &str) -> u32 {
|
||||||
|
if hay.contains(token) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
if subsequence(token, hay) {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
// Typo tolerance: compare the token with every word of the field.
|
||||||
|
let mut best = usize::MAX;
|
||||||
|
for word in hay.split(|c: char| !c.is_alphanumeric()) {
|
||||||
|
if word.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
best = best.min(levenshtein(token, word));
|
||||||
|
}
|
||||||
|
match best {
|
||||||
|
0..=1 => 25,
|
||||||
|
2 => 15,
|
||||||
|
3 => 5,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Do the characters of needle appear in order inside hay?
|
||||||
|
fn subsequence(needle: &str, hay: &str) -> bool {
|
||||||
|
let mut it = hay.chars();
|
||||||
|
needle.chars().all(|c| it.any(|h| h == c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classic Levenshtein distance on characters.
|
||||||
|
fn levenshtein(a: &str, b: &str) -> usize {
|
||||||
|
let a: Vec<char> = a.chars().collect();
|
||||||
|
let b: Vec<char> = b.chars().collect();
|
||||||
|
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||||
|
let mut cur = vec![0usize; b.len() + 1];
|
||||||
|
for (i, ca) in a.iter().enumerate() {
|
||||||
|
cur[0] = i + 1;
|
||||||
|
for (j, cb) in b.iter().enumerate() {
|
||||||
|
let cost = if ca == cb { 0 } else { 1 };
|
||||||
|
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut prev, &mut cur);
|
||||||
|
}
|
||||||
|
prev[b.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Catalog {
|
||||||
/// Sorted list of all categories present in the catalog.
|
/// Sorted list of all categories present in the catalog.
|
||||||
pub fn categories(&self) -> Vec<String> {
|
pub fn categories(&self) -> Vec<String> {
|
||||||
let mut cats: Vec<String> = self
|
let mut cats: Vec<String> = self
|
||||||
@@ -156,4 +246,50 @@ mod tests {
|
|||||||
let cats = cat.categories();
|
let cats = cat.categories();
|
||||||
assert!(cats.contains(&"coding-agent".to_string()));
|
assert!(cats.contains(&"coding-agent".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_search_tolerates_missing_separator() {
|
||||||
|
let cat = catalog();
|
||||||
|
let hits = cat.search("claude cod", None);
|
||||||
|
assert!(
|
||||||
|
hits.iter().any(|a| a.name == "claude-code"),
|
||||||
|
"expected claude-code in {:?}",
|
||||||
|
hits.iter().map(|a| &a.name).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_search_tolerates_typos() {
|
||||||
|
let cat = catalog();
|
||||||
|
let hits = cat.search("cludecode", None);
|
||||||
|
assert!(hits.iter().any(|a| a.name == "claude-code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_matches_rank_first() {
|
||||||
|
let cat = catalog();
|
||||||
|
let hits = cat.search("claude", None);
|
||||||
|
assert!(!hits.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
hits[0].name, "claude-code",
|
||||||
|
"exact name match must rank first: {:?}",
|
||||||
|
hits.iter().map(|a| &a.name).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suggest_proposes_close_names() {
|
||||||
|
let cat = catalog();
|
||||||
|
let sug = cat.suggest("aiderr");
|
||||||
|
assert!(sug.contains(&"aider".to_string()), "suggestions: {sug:?}");
|
||||||
|
assert!(sug.len() <= 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levenshtein_distance() {
|
||||||
|
assert_eq!(levenshtein("aider", "aider"), 0);
|
||||||
|
assert_eq!(levenshtein("aider", "aiderr"), 1);
|
||||||
|
assert_eq!(levenshtein("abc", "axc"), 1);
|
||||||
|
assert_eq!(levenshtein("abc", "xyz"), 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,16 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
if app.catalog.search(keyword, category).is_empty() {
|
if app.catalog.search(keyword, category).is_empty() {
|
||||||
app.log
|
let suggestions = app.catalog.suggest(keyword);
|
||||||
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
|
if suggestions.is_empty() {
|
||||||
|
app.log
|
||||||
|
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
|
||||||
|
} else {
|
||||||
|
app.log.info(&format!(
|
||||||
|
"no agent matches '{keyword}' — vouliez-vous dire : {} ?",
|
||||||
|
suggestions.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user