1use std::sync::Arc;
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8use skm_core::SkillMetadata;
9
10use crate::error::{LlmError, SelectError};
11use crate::strategy::{
12 Confidence, LatencyClass, SelectionContext, SelectionResult, SelectionStrategy,
13};
14
15#[async_trait]
19pub trait LlmClient: Send + Sync {
20 async fn complete(&self, prompt: &str, max_tokens: usize) -> Result<String, LlmError>;
22
23 async fn complete_structured(
26 &self,
27 prompt: &str,
28 _schema: &serde_json::Value,
29 max_tokens: usize,
30 ) -> Result<serde_json::Value, LlmError> {
31 let text = self.complete(prompt, max_tokens).await?;
32 serde_json::from_str(&text).map_err(|e| LlmError::ParseError(e.to_string()))
33 }
34}
35
36#[derive(Debug, Clone)]
38pub struct LlmStrategyConfig {
39 pub system_prompt: String,
41
42 pub include_few_shot: bool,
44
45 pub max_candidates: usize,
47
48 pub temperature: f32,
50}
51
52impl Default for LlmStrategyConfig {
53 fn default() -> Self {
54 Self {
55 system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
56 include_few_shot: true,
57 max_candidates: 20,
58 temperature: 0.0,
59 }
60 }
61}
62
63const 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.
64
65Respond ONLY with valid JSON in this format:
66{"skills": [{"name": "skill-name", "confidence": 0.0-1.0, "reasoning": "brief explanation"}]}
67
68If no skill is appropriate, return: {"skills": []}
69"#;
70
71#[derive(Debug, Deserialize)]
73struct LlmResponse {
74 skills: Vec<SkillSelection>,
75}
76
77#[derive(Debug, Deserialize)]
78struct SkillSelection {
79 name: String,
80 confidence: f32,
81 #[serde(default)]
82 reasoning: Option<String>,
83}
84
85pub struct LlmStrategy {
87 client: Arc<dyn LlmClient>,
88 config: LlmStrategyConfig,
89}
90
91impl LlmStrategy {
92 pub fn new(client: Arc<dyn LlmClient>, config: LlmStrategyConfig) -> Self {
94 Self { client, config }
95 }
96
97 fn build_prompt(&self, query: &str, candidates: &[&SkillMetadata]) -> String {
99 let mut prompt = self.config.system_prompt.clone();
100 prompt.push_str("\n\nAvailable skills:\n");
101
102 for (i, skill) in candidates.iter().take(self.config.max_candidates).enumerate() {
103 prompt.push_str(&format!("{}. {}: {}\n", i + 1, skill.name, skill.description));
104 }
105
106 prompt.push_str(&format!("\nUser query: \"{}\"\n", query));
107 prompt.push_str("\nWhich skill(s) should handle this query? Respond with JSON:");
108
109 prompt
110 }
111
112 fn parse_response(
114 &self,
115 response: &str,
116 candidates: &[&SkillMetadata],
117 ) -> Result<Vec<SelectionResult>, SelectError> {
118 let json_str = if let Some(start) = response.find('{') {
120 if let Some(end) = response.rfind('}') {
121 &response[start..=end]
122 } else {
123 response
124 }
125 } else {
126 response
127 };
128
129 let parsed: LlmResponse = serde_json::from_str(json_str)
130 .map_err(|e| SelectError::Llm(LlmError::ParseError(e.to_string())))?;
131
132 let candidate_names: std::collections::HashSet<_> =
134 candidates.iter().map(|c| c.name.as_str()).collect();
135
136 let results: Vec<SelectionResult> = parsed
137 .skills
138 .into_iter()
139 .filter(|s| candidate_names.contains(s.name.as_str()))
140 .map(|s| {
141 let confidence = Confidence::from_score(s.confidence);
142 let skill_name = skm_core::SkillName::new(&s.name).unwrap_or_else(|_| {
143 skm_core::SkillName::new("unknown").unwrap()
144 });
145
146 let mut result = SelectionResult::new(skill_name, s.confidence, confidence, "llm");
147 if let Some(reasoning) = s.reasoning {
148 result = result.with_reasoning(reasoning);
149 }
150 result
151 })
152 .collect();
153
154 Ok(results)
155 }
156}
157
158#[async_trait]
159impl SelectionStrategy for LlmStrategy {
160 async fn select(
161 &self,
162 query: &str,
163 candidates: &[&SkillMetadata],
164 _ctx: &SelectionContext,
165 ) -> Result<Vec<SelectionResult>, SelectError> {
166 if candidates.is_empty() {
167 return Ok(Vec::new());
168 }
169
170 let prompt = self.build_prompt(query, candidates);
171 let response = self.client.complete(&prompt, 500).await?;
172 self.parse_response(&response, candidates)
173 }
174
175 fn name(&self) -> &str {
176 "llm"
177 }
178
179 fn latency_class(&self) -> LatencyClass {
180 LatencyClass::Seconds
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::path::PathBuf;
188
189 struct MockLlmClient {
190 response: String,
191 }
192
193 impl MockLlmClient {
194 fn new(response: &str) -> Self {
195 Self {
196 response: response.to_string(),
197 }
198 }
199 }
200
201 #[async_trait]
202 impl LlmClient for MockLlmClient {
203 async fn complete(&self, _prompt: &str, _max_tokens: usize) -> Result<String, LlmError> {
204 Ok(self.response.clone())
205 }
206 }
207
208 fn make_metadata(name: &str, description: &str) -> SkillMetadata {
209 SkillMetadata {
210 name: skm_core::SkillName::new(name).unwrap(),
211 description: description.to_string(),
212 tags: Vec::new(),
213 triggers: Vec::new(),
214 source_path: PathBuf::new(),
215 content_hash: 0,
216 estimated_tokens: 100,
217 }
218 }
219
220 #[tokio::test]
221 async fn test_llm_strategy() {
222 let response = r#"{"skills": [{"name": "pdf-skill", "confidence": 0.9, "reasoning": "Query mentions PDF"}]}"#;
223 let client = Arc::new(MockLlmClient::new(response));
224
225 let strategy = LlmStrategy::new(client, LlmStrategyConfig::default());
226
227 let skills = vec![
228 make_metadata("pdf-skill", "Extract text from PDFs"),
229 make_metadata("weather-skill", "Get weather info"),
230 ];
231 let refs: Vec<_> = skills.iter().collect();
232 let ctx = SelectionContext::new();
233
234 let results = strategy.select("extract pdf text", &refs, &ctx).await.unwrap();
235
236 assert_eq!(results.len(), 1);
237 assert_eq!(results[0].skill.as_str(), "pdf-skill");
238 assert_eq!(results[0].score, 0.9);
239 assert!(results[0].reasoning.is_some());
240 }
241
242 #[tokio::test]
243 async fn test_llm_strategy_no_match() {
244 let response = r#"{"skills": []}"#;
245 let client = Arc::new(MockLlmClient::new(response));
246
247 let strategy = LlmStrategy::new(client, LlmStrategyConfig::default());
248
249 let skills = vec![make_metadata("pdf-skill", "Extract text from PDFs")];
250 let refs: Vec<_> = skills.iter().collect();
251 let ctx = SelectionContext::new();
252
253 let results = strategy.select("play music", &refs, &ctx).await.unwrap();
254
255 assert!(results.is_empty());
256 }
257
258 #[tokio::test]
259 async fn test_llm_strategy_invalid_skill() {
260 let response = r#"{"skills": [{"name": "nonexistent-skill", "confidence": 0.9}]}"#;
261 let client = Arc::new(MockLlmClient::new(response));
262
263 let strategy = LlmStrategy::new(client, LlmStrategyConfig::default());
264
265 let skills = vec![make_metadata("pdf-skill", "Extract text from PDFs")];
266 let refs: Vec<_> = skills.iter().collect();
267 let ctx = SelectionContext::new();
268
269 let results = strategy.select("query", &refs, &ctx).await.unwrap();
270
271 assert!(results.is_empty());
273 }
274
275 #[test]
276 fn test_build_prompt() {
277 let client = Arc::new(MockLlmClient::new(""));
278 let strategy = LlmStrategy::new(client, LlmStrategyConfig::default());
279
280 let skills = vec![
281 make_metadata("pdf-skill", "Extract text from PDFs"),
282 make_metadata("weather-skill", "Get weather info"),
283 ];
284 let refs: Vec<_> = skills.iter().collect();
285
286 let prompt = strategy.build_prompt("test query", &refs);
287
288 assert!(prompt.contains("pdf-skill"));
289 assert!(prompt.contains("weather-skill"));
290 assert!(prompt.contains("test query"));
291 }
292}