use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use skm_core::SkillMetadata;
use crate::error::{LlmError, SelectError};
use crate::strategy::{
Confidence, LatencyClass, SelectionContext, SelectionResult, SelectionStrategy,
};
#[async_trait]
pub trait LlmClient: Send + Sync {
async fn complete(&self, prompt: &str, max_tokens: usize) -> Result<String, LlmError>;
async fn complete_structured(
&self,
prompt: &str,
_schema: &serde_json::Value,
max_tokens: usize,
) -> Result<serde_json::Value, LlmError> {
let text = self.complete(prompt, max_tokens).await?;
serde_json::from_str(&text).map_err(|e| LlmError::ParseError(e.to_string()))
}
}
#[derive(Debug, Clone)]
pub struct LlmStrategyConfig {
pub system_prompt: String,
pub include_few_shot: bool,
pub max_candidates: usize,
pub temperature: f32,
}
impl Default for LlmStrategyConfig {
fn default() -> Self {
Self {
system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
include_few_shot: true,
max_candidates: 20,
temperature: 0.0,
}
}
}
const DEFAULT_SYSTEM_PROMPT: &str = r#"You are a skill selector. Given a user query and a list of available skills, determine which skill(s) should handle the query.
Respond ONLY with valid JSON in this format:
{"skills": [{"name": "skill-name", "confidence": 0.0-1.0, "reasoning": "brief explanation"}]}
If no skill is appropriate, return: {"skills": []}
"#;
#[derive(Debug, Deserialize)]
struct LlmResponse {
skills: Vec<SkillSelection>,
}
#[derive(Debug, Deserialize)]
struct SkillSelection {
name: String,
confidence: f32,
#[serde(default)]
reasoning: Option<String>,
}
pub struct LlmStrategy {
client: Arc<dyn LlmClient>,
config: LlmStrategyConfig,
}
impl LlmStrategy {
pub fn new(client: Arc<dyn LlmClient>, config: LlmStrategyConfig) -> Self {
Self { client, config }
}
fn build_prompt(&self, query: &str, candidates: &[&SkillMetadata]) -> String {
let mut prompt = self.config.system_prompt.clone();
prompt.push_str("\n\nAvailable skills:\n");
for (i, skill) in candidates.iter().take(self.config.max_candidates).enumerate() {
prompt.push_str(&format!("{}. {}: {}\n", i + 1, skill.name, skill.description));
}
prompt.push_str(&format!("\nUser query: \"{}\"\n", query));
prompt.push_str("\nWhich skill(s) should handle this query? Respond with JSON:");
prompt
}
fn parse_response(
&self,
response: &str,
candidates: &[&SkillMetadata],
) -> Result<Vec<SelectionResult>, SelectError> {
let json_str = if let Some(start) = response.find('{') {
if let Some(end) = response.rfind('}') {
&response[start..=end]
} else {
response
}
} else {
response
};
let parsed: LlmResponse = serde_json::from_str(json_str)
.map_err(|e| SelectError::Llm(LlmError::ParseError(e.to_string())))?;
let candidate_names: std::collections::HashSet<_> =
candidates.iter().map(|c| c.name.as_str()).collect();
let results: Vec<SelectionResult> = parsed
.skills
.into_iter()
.filter(|s| candidate_names.contains(s.name.as_str()))
.map(|s| {
let confidence = Confidence::from_score(s.confidence);
let skill_name = skm_core::SkillName::new(&s.name).unwrap_or_else(|_| {
skm_core::SkillName::new("unknown").unwrap()
});
let mut result = SelectionResult::new(skill_name, s.confidence, confidence, "llm");
if let Some(reasoning) = s.reasoning {
result = result.with_reasoning(reasoning);
}
result
})
.collect();
Ok(results)
}
}
#[async_trait]
impl SelectionStrategy for LlmStrategy {
async fn select(
&self,
query: &str,
candidates: &[&SkillMetadata],
_ctx: &SelectionContext,
) -> Result<Vec<SelectionResult>, SelectError> {
if candidates.is_empty() {
return Ok(Vec::new());
}
let prompt = self.build_prompt(query, candidates);
let response = self.client.complete(&prompt, 500).await?;
self.parse_response(&response, candidates)
}
fn name(&self) -> &str {
"llm"
}
fn latency_class(&self) -> LatencyClass {
LatencyClass::Seconds
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
struct MockLlmClient {
response: String,
}
impl MockLlmClient {
fn new(response: &str) -> Self {
Self {
response: response.to_string(),
}
}
}
#[async_trait]
impl LlmClient for MockLlmClient {
async fn complete(&self, _prompt: &str, _max_tokens: usize) -> Result<String, LlmError> {
Ok(self.response.clone())
}
}
fn make_metadata(name: &str, description: &str) -> SkillMetadata {
SkillMetadata {
name: skm_core::SkillName::new(name).unwrap(),
description: description.to_string(),
tags: Vec::new(),
triggers: Vec::new(),
source_path: PathBuf::new(),
content_hash: 0,
estimated_tokens: 100,
}
}
#[tokio::test]
async fn test_llm_strategy() {
let response = r#"{"skills": [{"name": "pdf-skill", "confidence": 0.9, "reasoning": "Query mentions PDF"}]}"#;
let client = Arc::new(MockLlmClient::new(response));
let strategy = LlmStrategy::new(client, LlmStrategyConfig::default());
let skills = vec![
make_metadata("pdf-skill", "Extract text from PDFs"),
make_metadata("weather-skill", "Get weather info"),
];
let refs: Vec<_> = skills.iter().collect();
let ctx = SelectionContext::new();
let results = strategy.select("extract pdf text", &refs, &ctx).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].skill.as_str(), "pdf-skill");
assert_eq!(results[0].score, 0.9);
assert!(results[0].reasoning.is_some());
}
#[tokio::test]
async fn test_llm_strategy_no_match() {
let response = r#"{"skills": []}"#;
let client = Arc::new(MockLlmClient::new(response));
let strategy = LlmStrategy::new(client, LlmStrategyConfig::default());
let skills = vec![make_metadata("pdf-skill", "Extract text from PDFs")];
let refs: Vec<_> = skills.iter().collect();
let ctx = SelectionContext::new();
let results = strategy.select("play music", &refs, &ctx).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn test_llm_strategy_invalid_skill() {
let response = r#"{"skills": [{"name": "nonexistent-skill", "confidence": 0.9}]}"#;
let client = Arc::new(MockLlmClient::new(response));
let strategy = LlmStrategy::new(client, LlmStrategyConfig::default());
let skills = vec![make_metadata("pdf-skill", "Extract text from PDFs")];
let refs: Vec<_> = skills.iter().collect();
let ctx = SelectionContext::new();
let results = strategy.select("query", &refs, &ctx).await.unwrap();
assert!(results.is_empty());
}
#[test]
fn test_build_prompt() {
let client = Arc::new(MockLlmClient::new(""));
let strategy = LlmStrategy::new(client, LlmStrategyConfig::default());
let skills = vec![
make_metadata("pdf-skill", "Extract text from PDFs"),
make_metadata("weather-skill", "Get weather info"),
];
let refs: Vec<_> = skills.iter().collect();
let prompt = strategy.build_prompt("test query", &refs);
assert!(prompt.contains("pdf-skill"));
assert!(prompt.contains("weather-skill"));
assert!(prompt.contains("test query"));
}
}