返回 CodeWhale
recommend.rs
根目录 / crates / tui / src / skills / recommend.rs
1 //! Deterministic, explainable suggestions for curated remote skills.
2 //!
3 //! This module deliberately has no network or install side effects. The slash
4 //! command fetches the configured registry under the existing network policy,
5 //! then this matcher ranks its metadata. A suggestion is never an install,
6 //! trust decision, or activation.
7
8 use crate::skills::install::{RegistryDocument, RegistryEntry};
9
10 const MIN_QUERY_CHARS: usize = 3;
11 const MAX_EXPLANATIONS: usize = 3;
12
13 /// One remote registry entry ranked for a user's task description.
14 #[derive(Debug)]
15 pub struct RemoteSkillRecommendation<'a> {
16 pub name: &'a str,
17 pub entry: &'a RegistryEntry,
18 /// The strongest, human-readable matching evidence, in rank order.
19 pub matched_terms: Vec<String>,
20 score: usize,
21 }
22
23 /// Rank up to `limit` remote skills for `query`.
24 ///
25 /// Explicit registry keywords and domains outrank name and description
26 /// fallback matches. Ties are resolved by the registry key, which is a
27 /// `BTreeMap`, making results stable across runs. Matching uses ASCII word
28 /// boundaries so `box` does not accidentally match `boxing`.
29 pub fn recommend_remote_skills<'a>(
30 query: &str,
31 registry: &'a RegistryDocument,
32 limit: usize,
33 ) -> Vec<RemoteSkillRecommendation<'a>> {
34 if limit == 0 || query.chars().count() < MIN_QUERY_CHARS {
35 return Vec::new();
36 }
37
38 let query = query.to_ascii_lowercase();
39 let mut recommendations = registry
40 .skills
41 .iter()
42 .filter_map(|(name, entry)| recommend_one(&query, name, entry))
43 .collect::<Vec<_>>();
44
45 recommendations.sort_by(|left, right| {
46 right
47 .score
48 .cmp(&left.score)
49 .then_with(|| left.name.cmp(right.name))
50 });
51 recommendations.truncate(limit);
52 recommendations
53 }
54
55 fn recommend_one<'a>(
56 query: &str,
57 name: &'a str,
58 entry: &'a RegistryEntry,
59 ) -> Option<RemoteSkillRecommendation<'a>> {
60 let mut matches = Vec::new();
61
62 for keyword in &entry.keywords {
63 add_phrase_match(query, keyword, "keyword", 900, &mut matches);
64 }
65 for domain in &entry.domains {
66 if let Some(domain) = normalize_domain(domain) {
67 add_phrase_match(query, &domain, "domain", 850, &mut matches);
68 }
69 }
70
71 add_phrase_match(query, name, "name", 800, &mut matches);
72 for term in word_terms(name) {
73 add_phrase_match(query, &term, "name", 700, &mut matches);
74 }
75
76 if let Some(description) = entry.description.as_deref() {
77 for term in word_terms(description) {
78 if !is_generic_description_term(&term) {
79 add_phrase_match(query, &term, "description", 120, &mut matches);
80 }
81 }
82 }
83
84 if matches.is_empty() {
85 return None;
86 }
87
88 matches.sort_by(|left, right| {
89 right
90 .score
91 .cmp(&left.score)
92 .then_with(|| left.reason.cmp(&right.reason))
93 });
94
95 let primary_score = matches[0].score;
96 let mut matched_terms = Vec::new();
97 let mut bonus = 0usize;
98 for found in matches {
99 if matched_terms
100 .iter()
101 .any(|existing| existing == &found.reason)
102 {
103 continue;
104 }
105 if !matched_terms.is_empty() {
106 // Extra evidence helps break close calls without allowing a long
107 // generic description to outrank an explicit keyword.
108 bonus += found.score.min(40);
109 }
110 matched_terms.push(found.reason);
111 if matched_terms.len() == MAX_EXPLANATIONS {
112 break;
113 }
114 }
115
116 Some(RemoteSkillRecommendation {
117 name,
118 entry,
119 matched_terms,
120 score: primary_score + bonus,
121 })
122 }
123
124 #[derive(Debug)]
125 struct Match {
126 score: usize,
127 reason: String,
128 }
129
130 fn add_phrase_match(
131 query: &str,
132 raw_term: &str,
133 label: &str,
134 base_score: usize,
135 out: &mut Vec<Match>,
136 ) {
137 let term = raw_term.trim().to_ascii_lowercase();
138 if term.chars().count() < MIN_QUERY_CHARS || !keyword_matches(query.as_bytes(), term.as_bytes())
139 {
140 return;
141 }
142
143 out.push(Match {
144 score: base_score + term.len(),
145 reason: format!("{label} `{term}`"),
146 });
147 }
148
149 fn normalize_domain(domain: &str) -> Option<String> {
150 let trimmed = domain.trim();
151 let after_scheme = trimmed.split_once("://").map_or(trimmed, |(_, rest)| rest);
152 let host = after_scheme
153 .split(['/', '?', '#'])
154 .next()
155 .unwrap_or(after_scheme)
156 .to_ascii_lowercase();
157 let host = host.strip_prefix("www.").unwrap_or(&host);
158 (!host.is_empty()).then(|| host.to_string())
159 }
160
161 fn word_terms(value: &str) -> Vec<String> {
162 value
163 .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
164 .map(str::trim)
165 .filter(|term| term.chars().count() >= MIN_QUERY_CHARS)
166 .map(str::to_ascii_lowercase)
167 .collect()
168 }
169
170 fn is_generic_description_term(term: &str) -> bool {
171 matches!(
172 term,
173 "about"
174 | "agent"
175 | "agents"
176 | "build"
177 | "create"
178 | "from"
179 | "help"
180 | "helps"
181 | "make"
182 | "skill"
183 | "skills"
184 | "task"
185 | "tasks"
186 | "that"
187 | "this"
188 | "tool"
189 | "tools"
190 | "using"
191 | "with"
192 | "work"
193 | "workflow"
194 | "workflows"
195 | "your"
196 )
197 }
198
199 fn keyword_matches(haystack: &[u8], keyword: &[u8]) -> bool {
200 if keyword.is_empty() || keyword.len() > haystack.len() {
201 return false;
202 }
203
204 haystack
205 .windows(keyword.len())
206 .enumerate()
207 .any(|(start, window)| {
208 if window != keyword {
209 return false;
210 }
211 let end = start + keyword.len();
212 let start_ok = start == 0 || is_word(haystack[start - 1]) != is_word(haystack[start]);
213 let end_ok =
214 end == haystack.len() || is_word(haystack[end - 1]) != is_word(haystack[end]);
215 start_ok && end_ok
216 })
217 }
218
219 fn is_word(byte: u8) -> bool {
220 byte.is_ascii_alphanumeric() || byte == b'_'
221 }
222
223 #[cfg(test)]
224 mod tests {
225 use std::collections::BTreeMap;
226
227 use super::*;
228
229 fn entry(description: &str, keywords: &[&str], domains: &[&str]) -> RegistryEntry {
230 RegistryEntry {
231 source: "github:example/skill".to_string(),
232 description: Some(description.to_string()),
233 keywords: keywords.iter().map(|value| (*value).to_string()).collect(),
234 domains: domains.iter().map(|value| (*value).to_string()).collect(),
235 }
236 }
237
238 fn registry(entries: &[(&str, RegistryEntry)]) -> RegistryDocument {
239 RegistryDocument {
240 skills: entries
241 .iter()
242 .map(|(name, entry)| ((*name).to_string(), entry.clone()))
243 .collect::<BTreeMap<_, _>>(),
244 }
245 }
246
247 #[test]
248 fn explicit_keywords_outrank_description_fallbacks() {
249 let registry = registry(&[
250 (
251 "notes",
252 entry("Create and organize spreadsheet notes", &[], &[]),
253 ),
254 (
255 "table-tools",
256 entry("Work with data files", &["spreadsheet"], &[]),
257 ),
258 ]);
259
260 let matches = recommend_remote_skills("clean up this spreadsheet", &registry, 3);
261
262 assert_eq!(matches[0].name, "table-tools");
263 assert_eq!(matches[0].matched_terms[0], "keyword `spreadsheet`");
264 }
265
266 #[test]
267 fn normalized_domains_match_pasted_urls() {
268 let registry = registry(&[(
269 "design",
270 entry(
271 "Design review workflow",
272 &[],
273 &["https://www.figma.com/files"],
274 ),
275 )]);
276
277 let matches = recommend_remote_skills(
278 "review https://www.figma.com/file/abc with me",
279 &registry,
280 3,
281 );
282
283 assert_eq!(matches.len(), 1);
284 assert_eq!(matches[0].matched_terms[0], "domain `figma.com`");
285 }
286
287 #[test]
288 fn short_queries_and_substrings_do_not_match() {
289 let registry = registry(&[("box", entry("Box workflow", &["box"], &[]))]);
290
291 assert!(recommend_remote_skills("go", &registry, 3).is_empty());
292 assert!(recommend_remote_skills("boxing", &registry, 3).is_empty());
293 }
294
295 #[test]
296 fn ties_are_stable_by_skill_name() {
297 let registry = registry(&[
298 ("beta", entry("", &["review"], &[])),
299 ("alpha", entry("", &["review"], &[])),
300 ]);
301
302 let matches = recommend_remote_skills("review this change", &registry, 3);
303
304 assert_eq!(
305 matches.iter().map(|item| item.name).collect::<Vec<_>>(),
306 vec!["alpha", "beta"]
307 );
308 }
309
310 #[test]
311 fn name_and_description_remain_backward_compatible_fallbacks() {
312 let registry = registry(&[("slide-deck", entry("Prepare presentation slides", &[], &[]))]);
313
314 let matches = recommend_remote_skills("prepare presentation slides", &registry, 3);
315
316 assert_eq!(matches.len(), 1);
317 assert!(
318 matches[0]
319 .matched_terms
320 .iter()
321 .any(|reason| reason == "description `presentation`"),
322 "expected explanation to include description fallback: {matches:#?}"
323 );
324 }
325 }
326
326 lines RUST