use async_trait::async_trait;
use regex::Regex;
use skm_core::{SkillMetadata, SkillName, SkillRegistry};
use crate::error::SelectError;
use crate::strategy::{
Confidence, LatencyClass, SelectionContext, SelectionResult, SelectionStrategy,
};
struct SkillMatcher {
skill_name: SkillName,
keywords: Vec<String>,
patterns: Vec<Regex>,
negative_patterns: Vec<Regex>,
}
impl SkillMatcher {
fn matches(&self, query: &str) -> Option<(f32, &'static str)> {
let query_lower = query.to_lowercase();
for pattern in &self.negative_patterns {
if pattern.is_match(&query_lower) {
return None;
}
}
for keyword in &self.keywords {
if query_lower.contains(keyword) {
let score = (keyword.len() as f32 / query_lower.len().max(1) as f32).min(1.0);
return Some((score.max(0.7), "keyword"));
}
}
for pattern in &self.patterns {
if pattern.is_match(&query_lower) {
return Some((0.8, "pattern"));
}
}
None
}
}
pub struct TriggerStrategy {
matchers: Vec<SkillMatcher>,
}
impl TriggerStrategy {
pub async fn from_registry(registry: &SkillRegistry) -> Result<Self, SelectError> {
let catalog = registry.catalog().await;
Self::from_metadata(&catalog)
}
pub fn from_metadata(skills: &[SkillMetadata]) -> Result<Self, SelectError> {
let mut matchers = Vec::new();
for meta in skills {
let mut keywords = Vec::new();
let mut patterns = Vec::new();
let mut negative_patterns = Vec::new();
for trigger in &meta.triggers {
let trigger = trigger.trim();
if trigger.is_empty() {
continue;
}
if trigger.starts_with('^') || trigger.contains('$') || trigger.contains('|') {
match Regex::new(&trigger.to_lowercase()) {
Ok(re) => patterns.push(re),
Err(e) => {
tracing::warn!(
"Invalid regex trigger '{}' for skill {}: {}",
trigger,
meta.name,
e
);
}
}
} else {
keywords.push(trigger.to_lowercase());
}
}
keywords.push(meta.name.as_str().to_string());
if !keywords.is_empty() || !patterns.is_empty() {
matchers.push(SkillMatcher {
skill_name: meta.name.clone(),
keywords,
patterns,
negative_patterns,
});
}
}
Ok(Self { matchers })
}
pub fn new() -> Self {
Self {
matchers: Vec::new(),
}
}
pub fn add_matcher(
&mut self,
skill_name: SkillName,
keywords: Vec<String>,
patterns: Vec<Regex>,
) {
self.matchers.push(SkillMatcher {
skill_name,
keywords,
patterns,
negative_patterns: Vec::new(),
});
}
}
impl Default for TriggerStrategy {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl SelectionStrategy for TriggerStrategy {
async fn select(
&self,
query: &str,
candidates: &[&SkillMetadata],
_ctx: &SelectionContext,
) -> Result<Vec<SelectionResult>, SelectError> {
let candidate_names: std::collections::HashSet<_> =
candidates.iter().map(|c| &c.name).collect();
let mut results = Vec::new();
for matcher in &self.matchers {
if !candidate_names.contains(&matcher.skill_name) {
continue;
}
if let Some((score, match_type)) = matcher.matches(query) {
let confidence = if score >= 0.9 {
Confidence::Definite
} else if score >= 0.7 {
Confidence::High
} else {
Confidence::Medium
};
results.push(
SelectionResult::new(matcher.skill_name.clone(), score, confidence, "trigger")
.with_reasoning(format!("Matched {}", match_type)),
);
}
}
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
Ok(results)
}
fn name(&self) -> &str {
"trigger"
}
fn latency_class(&self) -> LatencyClass {
LatencyClass::Microseconds
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_metadata(name: &str, triggers: Vec<&str>) -> SkillMetadata {
SkillMetadata {
name: SkillName::new(name).unwrap(),
description: "Test skill".to_string(),
tags: Vec::new(),
triggers: triggers.into_iter().map(String::from).collect(),
source_path: PathBuf::new(),
content_hash: 0,
estimated_tokens: 100,
}
}
#[tokio::test]
async fn test_trigger_keyword_match() {
let skills = vec![make_metadata("pdf-skill", vec!["pdf", ".pdf", "extract text"])];
let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
let ctx = SelectionContext::new();
let refs: Vec<_> = skills.iter().collect();
let results = strategy.select("extract text from pdf", &refs, &ctx).await.unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].skill.as_str(), "pdf-skill");
}
#[tokio::test]
async fn test_trigger_no_match() {
let skills = vec![make_metadata("pdf-skill", vec!["pdf"])];
let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
let ctx = SelectionContext::new();
let refs: Vec<_> = skills.iter().collect();
let results = strategy.select("create a spreadsheet", &refs, &ctx).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn test_trigger_regex_match() {
let skills = vec![make_metadata("email-skill", vec!["^send.*email"])];
let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
let ctx = SelectionContext::new();
let refs: Vec<_> = skills.iter().collect();
let results = strategy.select("send an email to john", &refs, &ctx).await.unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].skill.as_str(), "email-skill");
}
#[tokio::test]
async fn test_trigger_case_insensitive() {
let skills = vec![make_metadata("test-skill", vec!["PDF", "Document"])];
let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
let ctx = SelectionContext::new();
let refs: Vec<_> = skills.iter().collect();
let results = strategy.select("open the PDF document", &refs, &ctx).await.unwrap();
assert!(!results.is_empty());
}
#[tokio::test]
async fn test_trigger_implicit_name_match() {
let skills = vec![make_metadata("weather-lookup", vec![])];
let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
let ctx = SelectionContext::new();
let refs: Vec<_> = skills.iter().collect();
let results = strategy.select("use weather-lookup", &refs, &ctx).await.unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].skill.as_str(), "weather-lookup");
}
#[tokio::test]
async fn test_trigger_multiple_matches() {
let skills = vec![
make_metadata("pdf-skill", vec!["pdf", "document"]),
make_metadata("doc-skill", vec!["document", "word"]),
];
let strategy = TriggerStrategy::from_metadata(&skills).unwrap();
let ctx = SelectionContext::new();
let refs: Vec<_> = skills.iter().collect();
let results = strategy.select("open the document", &refs, &ctx).await.unwrap();
assert_eq!(results.len(), 2);
}
}