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