1use 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
21const RETIRE_EFFECTIVENESS_THRESHOLD: f32 = 0.3;
23const 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 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
429fn 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}