Skip to main content

skm_select/
trigger.rs

1//! Trigger-based selection strategy (µs latency).
2
3use 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
13/// Matcher for a single skill.
14struct SkillMatcher {
15    skill_name: SkillName,
16    keywords: Vec<String>,
17    patterns: Vec<Regex>,
18    negative_patterns: Vec<Regex>,
19}
20
21impl SkillMatcher {
22    /// Check if the query matches this skill.
23    fn matches(&self, query: &str) -> Option<(f32, &'static str)> {
24        let query_lower = query.to_lowercase();
25
26        // Check negative patterns first
27        for pattern in &self.negative_patterns {
28            if pattern.is_match(&query_lower) {
29                return None;
30            }
31        }
32
33        // Check exact keywords
34        for keyword in &self.keywords {
35            if query_lower.contains(keyword) {
36                // Longer keywords get higher scores
37                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        // Check regex patterns
43        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
53/// Fast-path matching using patterns defined in skill metadata.
54///
55/// Supports: exact keywords, regex patterns, glob patterns.
56/// Latency: µs (microseconds).
57pub struct TriggerStrategy {
58    matchers: Vec<SkillMatcher>,
59}
60
61impl TriggerStrategy {
62    /// Build from registry. Extracts `metadata.triggers` from each skill.
63    pub async fn from_registry(registry: &SkillRegistry) -> Result<Self, SelectError> {
64        let catalog = registry.catalog().await;
65        Self::from_metadata(&catalog)
66    }
67
68    /// Build from skill metadata.
69    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            // Parse triggers
78            for trigger in &meta.triggers {
79                let trigger = trigger.trim();
80                if trigger.is_empty() {
81                    continue;
82                }
83
84                // Check if it's a regex pattern (starts with ^ or contains special chars)
85                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                    // Treat as keyword
99                    keywords.push(trigger.to_lowercase());
100                }
101            }
102
103            // Add skill name as implicit keyword
104            keywords.push(meta.name.as_str().to_string());
105
106            // Parse negative triggers if present
107            // (Would need to extend SkillMetadata to include negative_triggers)
108
109            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    /// Create an empty strategy.
123    pub fn new() -> Self {
124        Self {
125            matchers: Vec::new(),
126        }
127    }
128
129    /// Add a matcher manually.
130    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            // Skip if not in candidates
166            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        // Sort by score descending
187        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        // Both should match "document"
300        assert_eq!(results.len(), 2);
301    }
302}