Skip to main content

skm_learn/
optimizer.rs

1//! LLM-driven skill description optimization.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8use skm_core::{SkillName, SkillRegistry};
9use skm_select::{CascadeSelector, LlmClient, LlmError};
10
11use crate::error::LearnError;
12use crate::harness::{TestReport, TestSuite, TriggerTestHarness};
13
14/// Configuration for the optimizer.
15#[derive(Debug, Clone)]
16pub struct OptimizerConfig {
17    /// Maximum iterations.
18    pub max_iterations: usize,
19
20    /// Target accuracy to stop early.
21    pub target_accuracy: f32,
22
23    /// System prompt for the LLM.
24    pub system_prompt: String,
25}
26
27impl Default for OptimizerConfig {
28    fn default() -> Self {
29        Self {
30            max_iterations: 5,
31            target_accuracy: 0.95,
32            system_prompt: DEFAULT_OPTIMIZER_PROMPT.to_string(),
33        }
34    }
35}
36
37const DEFAULT_OPTIMIZER_PROMPT: &str = r#"You are a skill description optimizer. Given a skill's current description and test results, suggest an improved description that will help the skill be selected for appropriate queries.
38
39Rules:
401. Keep the description concise (under 200 characters)
412. Include key trigger words that users might use
423. Be specific about what the skill does
434. Avoid overly generic terms
44
45Respond with ONLY the new description, nothing else."#;
46
47/// Result of a single optimization iteration.
48#[derive(Debug, Clone)]
49pub struct OptimizationIteration {
50    /// Iteration number.
51    pub iteration: usize,
52
53    /// Description before optimization.
54    pub old_description: String,
55
56    /// Description after optimization.
57    pub new_description: String,
58
59    /// Accuracy before.
60    pub old_accuracy: f32,
61
62    /// Accuracy after.
63    pub new_accuracy: f32,
64
65    /// Whether we kept the new description.
66    pub accepted: bool,
67}
68
69/// Result of the full optimization process.
70#[derive(Debug, Clone)]
71pub struct OptimizationResult {
72    /// Skill that was optimized.
73    pub skill: SkillName,
74
75    /// Final description.
76    pub final_description: String,
77
78    /// Iterations performed.
79    pub iterations: Vec<OptimizationIteration>,
80
81    /// Final accuracy.
82    pub final_accuracy: f32,
83
84    /// Initial accuracy.
85    pub initial_accuracy: f32,
86}
87
88/// LLM-driven description optimizer.
89pub struct DescriptionOptimizer {
90    llm: Arc<dyn LlmClient>,
91    config: OptimizerConfig,
92}
93
94impl DescriptionOptimizer {
95    /// Create a new optimizer.
96    pub fn new(llm: Arc<dyn LlmClient>, config: OptimizerConfig) -> Self {
97        Self { llm, config }
98    }
99
100    /// Build a prompt for the LLM.
101    fn build_prompt(
102        &self,
103        skill_name: &SkillName,
104        current_description: &str,
105        test_report: &TestReport,
106    ) -> String {
107        let skill_report = test_report.per_skill.get(skill_name);
108
109        let mut prompt = self.config.system_prompt.clone();
110
111        prompt.push_str(&format!("\n\nSkill: {}\n", skill_name));
112        prompt.push_str(&format!("Current description: {}\n", current_description));
113
114        if let Some(report) = skill_report {
115            prompt.push_str(&format!("\nTest results:\n"));
116            prompt.push_str(&format!("- Precision: {:.1}%\n", report.precision() * 100.0));
117            prompt.push_str(&format!("- Recall: {:.1}%\n", report.recall() * 100.0));
118            prompt.push_str(&format!("- False positives: {}\n", report.false_positives));
119            prompt.push_str(&format!("- False negatives: {}\n", report.false_negatives));
120        }
121
122        // Add failed test cases for context
123        prompt.push_str("\nFailed test cases:\n");
124        for result in &test_report.results {
125            if !result.passed {
126                match &result.expected {
127                    crate::harness::TestExpectation::Single(exp) if exp == skill_name => {
128                        prompt.push_str(&format!(
129                            "- Query: \"{}\" (expected {}, got {:?})\n",
130                            result.name, skill_name, result.selected
131                        ));
132                    }
133                    crate::harness::TestExpectation::AnyOf(exps) if exps.contains(skill_name) => {
134                        prompt.push_str(&format!(
135                            "- Query: \"{}\" (expected {:?}, got {:?})\n",
136                            result.name, exps, result.selected
137                        ));
138                    }
139                    _ => {}
140                }
141            }
142        }
143
144        prompt.push_str("\nSuggest an improved description:");
145
146        prompt
147    }
148
149    /// Optimize a single skill's description.
150    ///
151    /// Note: This is a simplified implementation. In practice, you'd need
152    /// to actually update the SKILL.md file and rebuild the index.
153    pub async fn optimize(
154        &self,
155        skill: &SkillName,
156        suite: &TestSuite,
157        selector: &CascadeSelector,
158        registry: &SkillRegistry,
159    ) -> Result<OptimizationResult, LearnError> {
160        let harness = TriggerTestHarness::new();
161
162        // Get initial description
163        let skill_meta = registry
164            .get_metadata(skill)
165            .await
166            .ok_or_else(|| LearnError::Optimizer(format!("Skill not found: {}", skill)))?;
167
168        let mut current_description = skill_meta.description.clone();
169        let mut iterations = Vec::new();
170
171        // Run initial test
172        let initial_report = harness.run(suite, selector, registry).await?;
173        let initial_accuracy = initial_report.accuracy();
174        let mut best_accuracy = initial_accuracy;
175
176        if initial_accuracy >= self.config.target_accuracy {
177            return Ok(OptimizationResult {
178                skill: skill.clone(),
179                final_description: current_description,
180                iterations,
181                final_accuracy: initial_accuracy,
182                initial_accuracy,
183            });
184        }
185
186        for i in 0..self.config.max_iterations {
187            // Generate new description
188            let prompt = self.build_prompt(skill, &current_description, &initial_report);
189            let new_description = self
190                .llm
191                .complete(&prompt, 200)
192                .await
193                .map_err(|e| LearnError::Optimizer(format!("LLM error: {}", e)))?
194                .trim()
195                .to_string();
196
197            // In a real implementation, we would:
198            // 1. Update the skill file
199            // 2. Rebuild the index
200            // 3. Re-run tests
201
202            // For now, we just record the iteration
203            let iteration = OptimizationIteration {
204                iteration: i + 1,
205                old_description: current_description.clone(),
206                new_description: new_description.clone(),
207                old_accuracy: best_accuracy,
208                new_accuracy: best_accuracy, // Would be updated after re-testing
209                accepted: true, // Would be based on improvement
210            };
211
212            iterations.push(iteration);
213            current_description = new_description;
214
215            // Check if we've reached target
216            if best_accuracy >= self.config.target_accuracy {
217                break;
218            }
219        }
220
221        Ok(OptimizationResult {
222            skill: skill.clone(),
223            final_description: current_description,
224            iterations,
225            final_accuracy: best_accuracy,
226            initial_accuracy,
227        })
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    struct MockLlm;
236
237    #[async_trait]
238    impl LlmClient for MockLlm {
239        async fn complete(&self, _prompt: &str, _max_tokens: usize) -> Result<String, LlmError> {
240            Ok("Improved description for skill".to_string())
241        }
242    }
243
244    #[test]
245    fn test_optimizer_config_default() {
246        let config = OptimizerConfig::default();
247        assert_eq!(config.max_iterations, 5);
248        assert_eq!(config.target_accuracy, 0.95);
249    }
250
251    #[test]
252    fn test_build_prompt() {
253        let llm = Arc::new(MockLlm);
254        let optimizer = DescriptionOptimizer::new(llm, OptimizerConfig::default());
255
256        let skill = SkillName::new("test-skill").unwrap();
257        let report = TestReport {
258            suite_name: "test".to_string(),
259            results: Vec::new(),
260            per_skill: std::collections::HashMap::new(),
261            total: 0,
262            passed: 0,
263            avg_latency_ms: 0.0,
264        };
265
266        let prompt = optimizer.build_prompt(&skill, "Current description", &report);
267
268        assert!(prompt.contains("test-skill"));
269        assert!(prompt.contains("Current description"));
270    }
271}