use serde::{Deserialize, Serialize};
use crate::skills::SkillIndex;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RoutingStrategy {
Explicit {
skill_name: String,
},
KeywordMatch {
matched_triggers: Vec<String>,
skill_names: Vec<String>,
},
Semantic {
confidence: f32,
},
NoSkill,
}
impl RoutingStrategy {
pub fn is_explicit(&self) -> bool {
matches!(self, Self::Explicit { .. })
}
pub fn has_skill(&self) -> bool {
!matches!(self, Self::NoSkill)
}
pub fn primary_skill(&self) -> Option<&str> {
match self {
Self::Explicit { skill_name } => Some(skill_name),
Self::KeywordMatch { skill_names, .. } => skill_names.first().map(|s| s.as_str()),
Self::Semantic { .. } => None,
Self::NoSkill => None,
}
}
}
pub fn route(input: &str, skill_indices: &[SkillIndex]) -> RoutingStrategy {
if let Some(skill) = skill_indices.iter().find(|s| s.matches_command(input)) {
return RoutingStrategy::Explicit {
skill_name: skill.name.clone(),
};
}
let matches: Vec<_> = skill_indices
.iter()
.filter(|s| s.matches_triggers(input))
.collect();
if !matches.is_empty() {
let matched_triggers: Vec<String> = matches
.iter()
.flat_map(|s| {
s.triggers
.iter()
.filter(|t| input.to_lowercase().contains(&t.to_lowercase()))
.cloned()
})
.collect();
let skill_names: Vec<String> = matches.iter().map(|s| s.name.clone()).collect();
return RoutingStrategy::KeywordMatch {
matched_triggers,
skill_names,
};
}
if !skill_indices.is_empty() {
RoutingStrategy::Semantic { confidence: 0.0 }
} else {
RoutingStrategy::NoSkill
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::SourceType;
fn test_skills() -> Vec<SkillIndex> {
vec![
SkillIndex::new("commit", "Create a git commit")
.triggers(["git commit", "commit changes"])
.source_type(SourceType::User),
SkillIndex::new("review", "Review code changes")
.triggers(["code review", "review pr"])
.source_type(SourceType::Project),
]
}
#[test]
fn test_explicit_routing() {
let skills = test_skills();
let result = route("/commit", &skills);
assert!(
matches!(result, RoutingStrategy::Explicit { skill_name } if skill_name == "commit")
);
}
#[test]
fn test_keyword_routing() {
let skills = test_skills();
let result = route("I want to git commit these changes", &skills);
assert!(matches!(result, RoutingStrategy::KeywordMatch { .. }));
if let RoutingStrategy::KeywordMatch { skill_names, .. } = result {
assert!(skill_names.contains(&"commit".to_string()));
}
}
#[test]
fn test_no_match_semantic() {
let skills = test_skills();
let result = route("help me with this bug", &skills);
assert!(matches!(result, RoutingStrategy::Semantic { .. }));
}
#[test]
fn test_no_skill_empty_index() {
let skills: Vec<SkillIndex> = vec![];
let result = route("anything", &skills);
assert!(matches!(result, RoutingStrategy::NoSkill));
}
#[test]
fn test_explicit_takes_precedence() {
let skills = test_skills();
let result = route("/commit", &skills);
assert!(matches!(result, RoutingStrategy::Explicit { .. }));
let result = route("git commit these changes", &skills);
assert!(matches!(result, RoutingStrategy::KeywordMatch { .. }));
}
#[test]
fn test_routing_strategy_methods() {
let explicit = RoutingStrategy::Explicit {
skill_name: "test".to_string(),
};
assert!(explicit.is_explicit());
assert!(explicit.has_skill());
assert_eq!(explicit.primary_skill(), Some("test"));
let no_skill = RoutingStrategy::NoSkill;
assert!(!no_skill.is_explicit());
assert!(!no_skill.has_skill());
assert_eq!(no_skill.primary_skill(), None);
}
}