Skip to main content

skilllite_evolution/
prompt_learner.rs

1//! Prompt learner: extract rules and examples from execution feedback (EVO-3).
2
3use std::path::Path;
4
5use anyhow::Result;
6use rusqlite::{params, Connection};
7use tokio::task::block_in_place;
8
9use skilllite_core::planning::PlanningRule;
10
11use crate::feedback::compute_effectiveness;
12use crate::{
13    gatekeeper_l1_path, gatekeeper_l2_size, gatekeeper_l3_content, EvolutionLlm, EvolutionMessage,
14};
15use skilllite_fs::atomic_write;
16
17const RULE_EXTRACTION_PROMPT: &str = include_str!("seed/evolution_prompts/rule_extraction.seed.md");
18const EXAMPLE_GENERATION_PROMPT: &str =
19    include_str!("seed/evolution_prompts/example_generation.seed.md");
20
21/// Effectiveness below which a rule is retired (aligned with skill retire threshold).
22const RETIRE_EFFECTIVENESS_THRESHOLD: f32 = 0.3;
23/// Minimum trigger count before a rule is eligible for retirement (need enough data).
24const RETIRE_MIN_TRIGGER_COUNT: i64 = 5;
25
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27pub struct PlanningExample {
28    pub id: String,
29    pub task_pattern: String,
30    pub plan_template: String,
31    pub key_insight: String,
32    #[serde(default = "default_evolved_origin")]
33    pub origin: String,
34}
35
36fn default_evolved_origin() -> String {
37    "evolved".to_string()
38}
39
40pub async fn evolve_prompts<L: EvolutionLlm>(
41    chat_root: &Path,
42    llm: &L,
43    model: &str,
44    txn_id: &str,
45) -> Result<Vec<(String, String)>> {
46    let mut changes = Vec::new();
47
48    // Batch all DB operations in one block_in_place to reduce connection opens.
49    let (retired, extract_data, example_data) = block_in_place(|| {
50        let conn = crate::feedback::open_evolution_db(chat_root)?;
51        let retired = retire_low_effectiveness_rules_with_conn(chat_root, txn_id, &conn)?;
52        let successful = query_decisions_summary(&conn, true)?;
53        let failed = query_decisions_summary(&conn, false)?;
54        let example_candidate = conn.query_row(
55            "SELECT task_description, tools_detail, elapsed_ms
56             FROM decisions
57             WHERE evolved = 0 AND task_completed = 1 AND replans = 0
58                   AND failed_tools = 0 AND total_tools >= 3
59             ORDER BY total_tools DESC LIMIT 1",
60            [],
61            |row| {
62                Ok((
63                    row.get::<_, Option<String>>(0)?,
64                    row.get::<_, Option<String>>(1)?,
65                    row.get::<_, i64>(2)?,
66                ))
67            },
68        );
69        let example_data = example_candidate.ok();
70        Ok::<_, anyhow::Error>((retired, (successful, failed), example_data))
71    })?;
72
73    changes.extend(retired);
74
75    let rule_changes = extract_rules_from_data(chat_root, extract_data, llm, model).await?;
76    changes.extend(rule_changes);
77
78    let example_changes = generate_examples_from_data(chat_root, example_data, llm, model).await?;
79    changes.extend(example_changes);
80
81    let new_rules = changes.iter().filter(|(t, _)| t == "rule_added").count();
82    let new_examples = changes.iter().filter(|(t, _)| t == "example_added").count();
83    if !gatekeeper_l2_size(new_rules, new_examples, 0) {
84        tracing::warn!(
85            "Gatekeeper L2: evolution produced too many changes (rules={}, examples={}), truncating",
86            new_rules, new_examples
87        );
88        changes.truncate(5 + 3);
89    }
90
91    Ok(changes)
92}
93
94async fn extract_rules_from_data<L: EvolutionLlm>(
95    chat_root: &Path,
96    (successful, failed): (String, String),
97    llm: &L,
98    model: &str,
99) -> Result<Vec<(String, String)>> {
100    if successful.is_empty() && failed.is_empty() {
101        return Ok(Vec::new());
102    }
103
104    let existing_rules = crate::seed::load_rules(chat_root);
105    let existing_summary = existing_rules
106        .iter()
107        .map(|r| format!("- {}: {}", r.id, r.instruction))
108        .collect::<Vec<_>>()
109        .join("\n");
110
111    let prompt = RULE_EXTRACTION_PROMPT
112        .replace("{{existing_rules_summary}}", &existing_summary)
113        .replace("{{successful_decisions}}", &successful)
114        .replace("{{failed_decisions}}", &failed);
115
116    let messages = vec![EvolutionMessage::user(&prompt)];
117    let content = llm
118        .complete(&messages, model, 0.3)
119        .await?
120        .trim()
121        .to_string();
122
123    let parsed = match parse_rule_extraction_response(&content) {
124        Ok(rules) => rules,
125        Err(e) => {
126            let detail = format!("{} — raw: {:.200}", e, content);
127            tracing::warn!("Failed to parse LLM rule extraction output: {}", detail);
128            let _ = block_in_place(|| {
129                let conn = crate::feedback::open_evolution_db(chat_root)?;
130                let _ = crate::log_evolution_event(
131                    &conn,
132                    chat_root,
133                    "rule_extraction_parse_failed",
134                    "",
135                    &detail,
136                    "",
137                );
138                Ok::<_, anyhow::Error>(())
139            });
140            return Ok(Vec::new());
141        }
142    };
143    if parsed.is_empty() {
144        return Ok(Vec::new());
145    }
146
147    let mut valid_rules = Vec::new();
148    for rule in parsed {
149        if let Err(e) = gatekeeper_l3_content(&rule.instruction) {
150            tracing::warn!("L3 rejected rule {}: {}", rule.id, e);
151            continue;
152        }
153        if rule.priority < 50 || rule.priority > 79 {
154            tracing::warn!(
155                "Rule {} has invalid priority {} (must be 50-79), adjusting",
156                rule.id,
157                rule.priority
158            );
159            let mut r = rule;
160            r.priority = r.priority.clamp(50, 79);
161            valid_rules.push(r);
162        } else {
163            valid_rules.push(rule);
164        }
165    }
166
167    if valid_rules.is_empty() {
168        return Ok(Vec::new());
169    }
170
171    let mut all_rules = existing_rules;
172    let mut changes = Vec::new();
173
174    let available_slots = 50_usize.saturating_sub(all_rules.len());
175    let to_add = valid_rules.into_iter().take(available_slots);
176
177    for new_rule in to_add {
178        if all_rules.iter().any(|r| r.id == new_rule.id) {
179            continue;
180        }
181        changes.push(("rule_added".to_string(), new_rule.id.clone()));
182        all_rules.push(new_rule);
183    }
184
185    if !changes.is_empty() {
186        let path = chat_root.join("prompts").join("rules.json");
187        if !gatekeeper_l1_path(chat_root, &path, None) {
188            anyhow::bail!("Gatekeeper L1: rules.json path outside allowed directories");
189        }
190        let json = serde_json::to_string_pretty(&all_rules)?;
191        atomic_write(&path, &json)?;
192        tracing::info!("Added {} new rules via evolution", changes.len());
193    }
194
195    Ok(changes)
196}
197
198fn parse_rule_extraction_response(content: &str) -> Result<Vec<PlanningRule>> {
199    let json_str = extract_json_block(content);
200
201    let parsed: serde_json::Value = serde_json::from_str(&json_str)
202        .map_err(|e| anyhow::anyhow!("Failed to parse rule extraction JSON: {}", e))?;
203
204    let rules_array = parsed
205        .get("rules")
206        .and_then(|v| v.as_array())
207        .ok_or_else(|| anyhow::anyhow!("No 'rules' array in response"))?;
208
209    let mut rules = Vec::new();
210    for rule_val in rules_array {
211        let id = rule_val
212            .get("id")
213            .and_then(|v| v.as_str())
214            .unwrap_or("")
215            .to_string();
216        if id.is_empty() {
217            continue;
218        }
219        let instruction = rule_val
220            .get("instruction")
221            .and_then(|v| v.as_str())
222            .unwrap_or("")
223            .to_string();
224        if instruction.is_empty() || instruction.len() > 200 {
225            continue;
226        }
227        let priority = rule_val
228            .get("priority")
229            .and_then(|v| v.as_u64())
230            .unwrap_or(65) as u32;
231        let keywords: Vec<String> = rule_val
232            .get("keywords")
233            .and_then(|v| v.as_array())
234            .map(|arr| {
235                arr.iter()
236                    .filter_map(|v| v.as_str().map(String::from))
237                    .collect()
238            })
239            .unwrap_or_default();
240        let context_keywords: Vec<String> = rule_val
241            .get("context_keywords")
242            .and_then(|v| v.as_array())
243            .map(|arr| {
244                arr.iter()
245                    .filter_map(|v| v.as_str().map(String::from))
246                    .collect()
247            })
248            .unwrap_or_default();
249        let tool_hint = rule_val
250            .get("tool_hint")
251            .and_then(|v| v.as_str())
252            .filter(|s| !s.is_empty() && *s != "null")
253            .map(String::from);
254
255        rules.push(PlanningRule {
256            id,
257            priority,
258            keywords,
259            context_keywords,
260            tool_hint,
261            instruction,
262            mutable: true,
263            origin: "evolved".to_string(),
264            reusable: false,
265            effectiveness: None,
266            trigger_count: None,
267        });
268    }
269
270    Ok(rules)
271}
272
273async fn generate_examples_from_data<L: EvolutionLlm>(
274    chat_root: &Path,
275    example_data: Option<(Option<String>, Option<String>, i64)>,
276    llm: &L,
277    model: &str,
278) -> Result<Vec<(String, String)>> {
279    let (task_desc, tools_json, elapsed_ms) = match example_data {
280        Some(c) => c,
281        None => return Ok(Vec::new()),
282    };
283
284    let task_desc = task_desc.unwrap_or_default();
285    if task_desc.is_empty() {
286        return Ok(Vec::new());
287    }
288
289    let examples_path = chat_root.join("prompts").join("examples.json");
290    let existing_examples: Vec<PlanningExample> = if examples_path.exists() {
291        skilllite_fs::read_file(&examples_path)
292            .ok()
293            .and_then(|s| serde_json::from_str(&s).ok())
294            .unwrap_or_default()
295    } else {
296        Vec::new()
297    };
298
299    if existing_examples.len() >= 25 {
300        return Ok(Vec::new());
301    }
302
303    let existing_summary = existing_examples
304        .iter()
305        .map(|e| format!("- {}: {}", e.id, e.task_pattern))
306        .collect::<Vec<_>>()
307        .join("\n");
308
309    let tool_sequence = tools_json.unwrap_or_else(|| "[]".to_string());
310    let rules_used = "N/A".to_string();
311
312    let prompt = EXAMPLE_GENERATION_PROMPT
313        .replace("{{existing_examples_summary}}", &existing_summary)
314        .replace("{{task_description}}", &task_desc)
315        .replace("{{tool_sequence}}", &tool_sequence)
316        .replace("{{rules_used}}", &rules_used)
317        .replace("{{elapsed_ms}}", &elapsed_ms.to_string());
318
319    let messages = vec![EvolutionMessage::user(&prompt)];
320    let content = llm
321        .complete(&messages, model, 0.3)
322        .await?
323        .trim()
324        .to_string();
325
326    let example = match parse_example_response(&content) {
327        Ok(ex) => ex,
328        Err(e) => {
329            let detail = format!("{} — raw: {:.200}", e, content);
330            tracing::warn!("Failed to parse LLM example output: {}", detail);
331            let _ = block_in_place(|| {
332                let conn = crate::feedback::open_evolution_db(chat_root)?;
333                let _ = crate::log_evolution_event(
334                    &conn,
335                    chat_root,
336                    "example_generation_parse_failed",
337                    "",
338                    &detail,
339                    "",
340                );
341                Ok::<_, anyhow::Error>(())
342            });
343            return Ok(Vec::new());
344        }
345    };
346    let example = match example {
347        Some(e) => e,
348        None => return Ok(Vec::new()),
349    };
350
351    let combined = format!(
352        "{} {} {}",
353        example.task_pattern, example.plan_template, example.key_insight
354    );
355    if let Err(e) = gatekeeper_l3_content(&combined) {
356        tracing::warn!("L3 rejected example {}: {}", example.id, e);
357        return Ok(Vec::new());
358    }
359
360    if !gatekeeper_l1_path(chat_root, &examples_path, None) {
361        anyhow::bail!("Gatekeeper L1: examples.json path outside allowed directories");
362    }
363
364    let mut all_examples = existing_examples;
365    if all_examples.iter().any(|e| e.id == example.id) {
366        return Ok(Vec::new());
367    }
368
369    let change_id = example.id.clone();
370    all_examples.push(example);
371
372    let json = serde_json::to_string_pretty(&all_examples)?;
373    atomic_write(&examples_path, &json)?;
374    tracing::info!("Added new example: {}", change_id);
375
376    Ok(vec![("example_added".to_string(), change_id)])
377}
378
379fn parse_example_response(content: &str) -> Result<Option<PlanningExample>> {
380    let json_str = extract_json_block(content);
381
382    let parsed: serde_json::Value = serde_json::from_str(&json_str)
383        .map_err(|e| anyhow::anyhow!("Failed to parse example JSON: {}", e))?;
384
385    if let Some(skip) = parsed.get("skip_reason").and_then(|v| v.as_str()) {
386        if !skip.is_empty() && skip != "null" {
387            return Ok(None);
388        }
389    }
390
391    let example_val = parsed
392        .get("example")
393        .ok_or_else(|| anyhow::anyhow!("No 'example' field in response"))?;
394
395    let id = example_val
396        .get("id")
397        .and_then(|v| v.as_str())
398        .unwrap_or("")
399        .to_string();
400    let task_pattern = example_val
401        .get("task_pattern")
402        .and_then(|v| v.as_str())
403        .unwrap_or("")
404        .to_string();
405    let plan_template = example_val
406        .get("plan_template")
407        .and_then(|v| v.as_str())
408        .unwrap_or("")
409        .to_string();
410    let key_insight = example_val
411        .get("key_insight")
412        .and_then(|v| v.as_str())
413        .unwrap_or("")
414        .to_string();
415
416    if id.is_empty() || task_pattern.is_empty() || plan_template.is_empty() {
417        return Ok(None);
418    }
419
420    Ok(Some(PlanningExample {
421        id,
422        task_pattern,
423        plan_template,
424        key_insight,
425        origin: "evolved".to_string(),
426    }))
427}
428
429/// Retire rules with effectiveness below threshold and sufficient trigger history.
430/// Only mutable (evolved/external) rules are retired; seed rules are preserved.
431/// Returns `(change_type, rule_id)` pairs for changelog.
432fn retire_low_effectiveness_rules_with_conn(
433    chat_root: &Path,
434    txn_id: &str,
435    conn: &Connection,
436) -> Result<Vec<(String, String)>> {
437    let rules_path = chat_root.join("prompts").join("rules.json");
438    if !rules_path.exists() {
439        return Ok(Vec::new());
440    }
441    if !gatekeeper_l1_path(chat_root, &rules_path, None) {
442        anyhow::bail!("Gatekeeper L1: rules.json path outside allowed directories");
443    }
444    let content = skilllite_fs::read_file(&rules_path)?;
445    let rules: Vec<PlanningRule> = serde_json::from_str(&content)?;
446
447    let mut to_retire: Vec<(String, String)> = Vec::new();
448    let mut kept: Vec<PlanningRule> = Vec::new();
449
450    for rule in rules {
451        if !rule.mutable {
452            kept.push(rule);
453            continue;
454        }
455        let eff = compute_effectiveness(conn, &rule.id).unwrap_or(-1.0);
456        if eff < 0.0 {
457            kept.push(rule);
458            continue;
459        }
460        let trigger_count: i64 = conn
461            .query_row(
462                "SELECT COUNT(*) FROM decision_rules WHERE rule_id = ?1",
463                params![rule.id],
464                |row| row.get(0),
465            )
466            .unwrap_or(0);
467
468        if eff < RETIRE_EFFECTIVENESS_THRESHOLD && trigger_count >= RETIRE_MIN_TRIGGER_COUNT {
469            let reason = format!(
470                "effectiveness {:.0}% < {:.0}% threshold, trigger_count {}",
471                eff * 100.0,
472                RETIRE_EFFECTIVENESS_THRESHOLD * 100.0,
473                trigger_count
474            );
475            let _ = crate::log_evolution_event(
476                conn,
477                chat_root,
478                "rule_retired",
479                &rule.id,
480                &reason,
481                txn_id,
482            );
483            tracing::info!("Retired rule '{}': {}", rule.id, reason);
484            to_retire.push(("rule_retired".to_string(), rule.id));
485        } else {
486            kept.push(rule);
487        }
488    }
489
490    if to_retire.is_empty() {
491        return Ok(Vec::new());
492    }
493
494    let json = serde_json::to_string_pretty(&kept)?;
495    atomic_write(&rules_path, &json)?;
496
497    Ok(to_retire)
498}
499
500pub fn update_reusable_status(conn: &Connection, chat_root: &Path) -> Result<()> {
501    let rules_path = chat_root.join("prompts").join("rules.json");
502    if !rules_path.exists() {
503        return Ok(());
504    }
505
506    let content = skilllite_fs::read_file(&rules_path)?;
507    let mut rules: Vec<PlanningRule> = serde_json::from_str(&content)?;
508
509    let mut changed = false;
510    for rule in rules.iter_mut() {
511        if !rule.mutable {
512            continue;
513        }
514
515        let eff = compute_effectiveness(conn, &rule.id)?;
516        if eff < 0.0 {
517            continue;
518        }
519
520        let trigger_count: i64 = conn
521            .query_row(
522                "SELECT COUNT(*) FROM decision_rules WHERE rule_id = ?1",
523                params![rule.id],
524                |row| row.get(0),
525            )
526            .unwrap_or(0);
527
528        rule.effectiveness = Some(eff);
529        rule.trigger_count = Some(trigger_count as u32);
530
531        if !rule.reusable && eff >= 0.7 && trigger_count >= 5 {
532            rule.reusable = true;
533            changed = true;
534        } else if rule.reusable && eff < 0.5 {
535            rule.reusable = false;
536            changed = true;
537        }
538    }
539
540    if changed {
541        let json = serde_json::to_string_pretty(&rules)?;
542        atomic_write(&rules_path, &json)?;
543    }
544
545    Ok(())
546}
547
548fn query_decisions_summary(conn: &Connection, successful: bool) -> Result<String> {
549    let condition = if successful {
550        "evolved = 0 AND task_completed = 1 AND replans = 0 AND failed_tools = 0"
551    } else {
552        "evolved = 0 AND (replans > 0 OR failed_tools > 0)"
553    };
554
555    let sql = format!(
556        "SELECT task_description, total_tools, failed_tools, replans, elapsed_ms
557         FROM decisions WHERE {} AND task_description IS NOT NULL
558         ORDER BY ts DESC LIMIT 10",
559        condition
560    );
561
562    let mut stmt = conn.prepare(&sql)?;
563    let rows: Vec<String> = stmt
564        .query_map([], |row| {
565            let desc: String = row.get(0)?;
566            let total: i64 = row.get(1)?;
567            let failed: i64 = row.get(2)?;
568            let replans: i64 = row.get(3)?;
569            let elapsed: i64 = row.get(4)?;
570            Ok(format!(
571                "- 任务: {} | 工具调用: {} (失败: {}) | replan: {} | 耗时: {}ms",
572                desc, total, failed, replans, elapsed
573            ))
574        })?
575        .filter_map(|r| r.ok())
576        .collect();
577
578    Ok(rows.join("\n"))
579}
580
581pub fn extract_json_block(content: &str) -> String {
582    let content = crate::strip_think_blocks(content.trim());
583
584    if let Some(start) = content.find("```json") {
585        let json_start = start + 7;
586        if let Some(end) = content[json_start..].find("```") {
587            return content[json_start..json_start + end].trim().to_string();
588        }
589    }
590
591    if let Some(start) = content.find("```") {
592        let block_start = start + 3;
593        let actual_start = content[block_start..]
594            .find('\n')
595            .map(|n| block_start + n + 1)
596            .unwrap_or(block_start);
597        if let Some(end) = content[actual_start..].find("```") {
598            return content[actual_start..actual_start + end].trim().to_string();
599        }
600    }
601
602    if let (Some(start), Some(end)) = (content.find('{'), content.rfind('}')) {
603        if start < end {
604            return content[start..=end].to_string();
605        }
606    }
607
608    content.to_string()
609}
610
611#[cfg(test)]
612mod extract_json_tests {
613    use super::extract_json_block;
614
615    #[test]
616    fn extract_json_block_fenced_json() {
617        let s = "intro\n```json\n{\"a\":1}\n```\ntrailer";
618        assert_eq!(extract_json_block(s), "{\"a\":1}");
619    }
620
621    #[test]
622    fn extract_json_block_brace_span() {
623        let s = "prefix {\"x\": true} suffix";
624        assert_eq!(extract_json_block(s), "{\"x\": true}");
625    }
626
627    #[test]
628    fn extract_json_block_plain_after_strip_think() {
629        let s = "<think>\n</think>\n{\"k\":\"v\"}";
630        assert_eq!(extract_json_block(s), "{\"k\":\"v\"}");
631    }
632}