1use async_trait::async_trait;
4use regex::Regex;
5
6use skm_core::{SkillMetadata, SkillName, SkillRegistry};
7
8use crate::error::SelectError;
9use crate::strategy::{
10 Confidence, LatencyClass, SelectionContext, SelectionResult, SelectionStrategy,
11};
12
13struct SkillMatcher {
15 skill_name: SkillName,
16 keywords: Vec<String>,
17 patterns: Vec<Regex>,
18 negative_patterns: Vec<Regex>,
19}
20
21impl SkillMatcher {
22 fn matches(&self, query: &str) -> Option<(f32, &'static str)> {
24 let query_lower = query.to_lowercase();
25
26 for pattern in &self.negative_patterns {
28 if pattern.is_match(&query_lower) {
29 return None;
30 }
31 }
32
33 for keyword in &self.keywords {
35 if query_lower.contains(keyword) {
36 let score = (keyword.len() as f32 / query_lower.len().max(1) as f32).min(1.0);
38 return Some((score.max(0.7), "keyword"));
39 }
40 }
41
42 for pattern in &self.patterns {
44 if pattern.is_match(&query_lower) {
45 return Some((0.8, "pattern"));
46 }
47 }
48
49 None
50 }
51}
52
53pub struct TriggerStrategy {
58 matchers: Vec<SkillMatcher>,
59}
60
61impl TriggerStrategy {
62 pub async fn from_registry(registry: &SkillRegistry) -> Result<Self, SelectError> {
64 let catalog = registry.catalog().await;
65 Self::from_metadata(&catalog)
66 }
67
68 pub fn from_metadata(skills: &[SkillMetadata]) -> Result<Self, SelectError> {
70 let mut matchers = Vec::new();
71
72 for meta in skills {
73 let mut keywords = Vec::new();
74 let mut patterns = Vec::new();
75 let mut negative_patterns = Vec::new();
76
77 for trigger in &meta.triggers {
79 let trigger = trigger.trim();
80 if trigger.is_empty() {
81 continue;
82 }
83
84 if trigger.starts_with('^') || trigger.contains('$') || trigger.contains('|') {
86 match Regex::new(&trigger.to_lowercase()) {
87 Ok(re) => patterns.push(re),
88 Err(e) => {
89 tracing::warn!(
90 "Invalid regex trigger '{}' for skill {}: {}",
91 trigger,
92 meta.name,
93 e
94 );
95 }
96 }
97 } else {
98 keywords.push(trigger.to_lowercase());
100 }
101 }
102
103 keywords.push(meta.name.as_str().to_string());
105
106 if !keywords.is_empty() || !patterns.is_empty() {
110 matchers.push(SkillMatcher {
111 skill_name: meta.name.clone(),
112 keywords,
113 patterns,
114 negative_patterns,
115 });
116 }
117 }
118
119 Ok(Self { matchers })
120 }
121
122 pub fn new() -> Self {
124 Self {
125 matchers: Vec::new(),
126 }
127 }
128
129 pub fn add_matcher(
131 &mut self,
132 skill_name: SkillName,
133 keywords: Vec<String>,
134 patterns: Vec<Regex>,
135 ) {
136 self.matchers.push(SkillMatcher {
137 skill_name,
138 keywords,
139 patterns,
140 negative_patterns: Vec::new(),
141 });
142 }
143}
144
145impl Default for TriggerStrategy {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151#[async_trait]
152impl SelectionStrategy for TriggerStrategy {
153 async fn select(
154 &self,
155 query: &str,
156 candidates: &[&SkillMetadata],
157 _ctx: &SelectionContext,
158 ) -> Result<Vec<SelectionResult>, SelectError> {
159 let candidate_names: std::collections::HashSet<_> =
160 candidates.iter().map(|c| &c.name).collect();
161
162 let mut results = Vec::new();
163
164 for matcher in &self.matchers {
165 if !candidate_names.contains(&matcher.skill_name) {
167 continue;
168 }
169
170 if let Some((score, match_type)) = matcher.matches(query) {
171 let confidence = if score >= 0.9 {
172 Confidence::Definite
173 } else if score >= 0.7 {
174 Confidence::High
175 } else {
176 Confidence::Medium
177 };
178
179 results.push(
180 SelectionResult::new(matcher.skill_name.clone(), score, confidence, "trigger")
181 .with_reasoning(format!("Matched {}", match_type)),
182 );
183 }
184 }
185
186 results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
188
189 Ok(results)
190 }
191
192 fn name(&self) -> &str {
193 "trigger"
194 }
195
196 fn latency_class(&self) -> LatencyClass {
197 LatencyClass::Microseconds
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use std::path::PathBuf;
205
206 fn make_metadata(name: &str, triggers: Vec<&str>) -> SkillMetadata {
207 SkillMetadata {
208 name: SkillName::new(name).unwrap(),
209 description: "Test skill".to_string(),
210 tags: Vec::new(),
211 triggers: triggers.into_iter().map(String::from).collect(),
212 source_path: PathBuf::new(),
213 content_hash: 0,
214 estimated_tokens: 100,
215 }
216 }
217
218 #[tokio::test]
219 async fn test_trigger_keyword_match() {
220 let skills = vec![make_metadata("pdf-skill", vec!["pdf", ".pdf", "extract text"])];
221
222 let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
223 let ctx = SelectionContext::new();
224 let refs: Vec<_> = skills.iter().collect();
225
226 let results = strategy.select("extract text from pdf", &refs, &ctx).await.unwrap();
227
228 assert!(!results.is_empty());
229 assert_eq!(results[0].skill.as_str(), "pdf-skill");
230 }
231
232 #[tokio::test]
233 async fn test_trigger_no_match() {
234 let skills = vec![make_metadata("pdf-skill", vec!["pdf"])];
235
236 let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
237 let ctx = SelectionContext::new();
238 let refs: Vec<_> = skills.iter().collect();
239
240 let results = strategy.select("create a spreadsheet", &refs, &ctx).await.unwrap();
241
242 assert!(results.is_empty());
243 }
244
245 #[tokio::test]
246 async fn test_trigger_regex_match() {
247 let skills = vec![make_metadata("email-skill", vec!["^send.*email"])];
248
249 let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
250 let ctx = SelectionContext::new();
251 let refs: Vec<_> = skills.iter().collect();
252
253 let results = strategy.select("send an email to john", &refs, &ctx).await.unwrap();
254
255 assert!(!results.is_empty());
256 assert_eq!(results[0].skill.as_str(), "email-skill");
257 }
258
259 #[tokio::test]
260 async fn test_trigger_case_insensitive() {
261 let skills = vec![make_metadata("test-skill", vec!["PDF", "Document"])];
262
263 let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
264 let ctx = SelectionContext::new();
265 let refs: Vec<_> = skills.iter().collect();
266
267 let results = strategy.select("open the PDF document", &refs, &ctx).await.unwrap();
268
269 assert!(!results.is_empty());
270 }
271
272 #[tokio::test]
273 async fn test_trigger_implicit_name_match() {
274 let skills = vec![make_metadata("weather-lookup", vec![])];
275
276 let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
277 let ctx = SelectionContext::new();
278 let refs: Vec<_> = skills.iter().collect();
279
280 let results = strategy.select("use weather-lookup", &refs, &ctx).await.unwrap();
281
282 assert!(!results.is_empty());
283 assert_eq!(results[0].skill.as_str(), "weather-lookup");
284 }
285
286 #[tokio::test]
287 async fn test_trigger_multiple_matches() {
288 let skills = vec![
289 make_metadata("pdf-skill", vec!["pdf", "document"]),
290 make_metadata("doc-skill", vec!["document", "word"]),
291 ];
292
293 let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
294 let ctx = SelectionContext::new();
295 let refs: Vec<_> = skills.iter().collect();
296
297 let results = strategy.select("open the document", &refs, &ctx).await.unwrap();
298
299 assert_eq!(results.len(), 2);
301 }
302}