Skip to main content

skilllite_evolution/seed/
mod.rs

1//! Seed data management for the self-evolving engine (EVO-2 + EVO-6).
2
3use std::path::{Path, PathBuf};
4
5use skilllite_core::planning::{PlanningRule, SourceRegistry};
6
7const SEED_VERSION: u32 = 1;
8
9const SEED_RULES: &str = include_str!("rules.seed.json");
10const SEED_SOURCES: &str = include_str!("sources.seed.json");
11const SEED_SYSTEM: &str = include_str!("system.seed.md");
12const SEED_PLANNING: &str = include_str!("planning.seed.md");
13const SEED_EXECUTION: &str = include_str!("execution.seed.md");
14const SEED_EXAMPLES: &str = include_str!("examples.seed.md");
15
16fn prompts_dir(chat_root: &Path) -> PathBuf {
17    chat_root.join("prompts")
18}
19
20pub fn ensure_seed_data(chat_root: &Path) {
21    let dir = prompts_dir(chat_root);
22    let version_file = dir.join(".seed_version");
23
24    let current_version = std::fs::read_to_string(&version_file)
25        .ok()
26        .and_then(|s| s.trim().parse::<u32>().ok())
27        .unwrap_or(0);
28
29    if current_version >= SEED_VERSION {
30        return;
31    }
32
33    if std::fs::create_dir_all(&dir).is_err() {
34        tracing::warn!("Failed to create prompts dir: {}", dir.display());
35        return;
36    }
37
38    let rules_exist = dir.join("rules.json").exists();
39    if !rules_exist {
40        write_seed_file(&dir, "rules.json", SEED_RULES);
41        write_seed_file(&dir, "sources.json", SEED_SOURCES);
42        write_seed_file(&dir, "system.md", SEED_SYSTEM);
43        write_seed_file(&dir, "planning.md", SEED_PLANNING);
44        write_seed_file(&dir, "execution.md", SEED_EXECUTION);
45        write_seed_file(&dir, "examples.md", SEED_EXAMPLES);
46    } else {
47        merge_seed_rules(&dir);
48        merge_seed_sources(&dir);
49        write_if_unchanged(&dir, "system.md", SEED_SYSTEM);
50        write_if_unchanged(&dir, "planning.md", SEED_PLANNING);
51        write_if_unchanged(&dir, "execution.md", SEED_EXECUTION);
52        write_if_unchanged(&dir, "examples.md", SEED_EXAMPLES);
53    }
54
55    let _ = std::fs::write(&version_file, SEED_VERSION.to_string());
56    tracing::info!("Seed data v{} written to {}", SEED_VERSION, dir.display());
57}
58
59pub fn ensure_seed_data_force(chat_root: &Path) {
60    let dir = prompts_dir(chat_root);
61    if std::fs::create_dir_all(&dir).is_err() {
62        tracing::warn!("Failed to create prompts dir: {}", dir.display());
63        return;
64    }
65    write_seed_file(&dir, "rules.json", SEED_RULES);
66    write_seed_file(&dir, "sources.json", SEED_SOURCES);
67    write_seed_file(&dir, "system.md", SEED_SYSTEM);
68    write_seed_file(&dir, "planning.md", SEED_PLANNING);
69    write_seed_file(&dir, "execution.md", SEED_EXECUTION);
70    write_seed_file(&dir, "examples.md", SEED_EXAMPLES);
71    let _ = std::fs::write(dir.join(".seed_version"), SEED_VERSION.to_string());
72    tracing::info!("Seed data force-reset to v{}", SEED_VERSION);
73}
74
75fn write_seed_file(dir: &Path, name: &str, content: &str) {
76    let path = dir.join(name);
77    if let Err(e) = std::fs::write(&path, content) {
78        tracing::warn!("Failed to write seed file {}: {}", path.display(), e);
79    }
80}
81
82fn write_if_unchanged(dir: &Path, name: &str, new_content: &str) {
83    let path = dir.join(name);
84    if !path.exists() {
85        write_seed_file(dir, name, new_content);
86        return;
87    }
88    if let Ok(existing) = std::fs::read_to_string(&path) {
89        if existing.trim() == new_content.trim() {
90            return;
91        }
92    }
93    write_seed_file(dir, name, new_content);
94}
95
96fn merge_seed_rules(dir: &Path) {
97    let rules_path = dir.join("rules.json");
98    let existing: Vec<PlanningRule> = if rules_path.exists() {
99        std::fs::read_to_string(&rules_path)
100            .ok()
101            .and_then(|s| serde_json::from_str(&s).ok())
102            .unwrap_or_default()
103    } else {
104        Vec::new()
105    };
106
107    let seed: Vec<PlanningRule> = serde_json::from_str(SEED_RULES).unwrap_or_default();
108
109    let mut merged = existing.clone();
110    for seed_rule in &seed {
111        let exists = merged.iter().any(|r| r.id == seed_rule.id);
112        if !exists {
113            merged.push(seed_rule.clone());
114        }
115        if let Some(existing_rule) = merged
116            .iter_mut()
117            .find(|r| r.id == seed_rule.id && !r.mutable)
118        {
119            *existing_rule = seed_rule.clone();
120        }
121    }
122
123    if let Ok(json) = serde_json::to_string_pretty(&merged) {
124        write_seed_file(dir, "rules.json", &json);
125    }
126}
127
128fn merge_seed_sources(dir: &Path) {
129    let sources_path = dir.join("sources.json");
130    let seed_registry: SourceRegistry = match serde_json::from_str(SEED_SOURCES) {
131        Ok(r) => r,
132        Err(e) => {
133            tracing::warn!("Failed to parse SEED_SOURCES: {}", e);
134            return;
135        }
136    };
137
138    let mut existing_registry: SourceRegistry = if sources_path.exists() {
139        std::fs::read_to_string(&sources_path)
140            .ok()
141            .and_then(|s| serde_json::from_str(&s).ok())
142            .unwrap_or_else(|| SourceRegistry {
143                version: 1,
144                sources: Vec::new(),
145            })
146    } else {
147        SourceRegistry {
148            version: 1,
149            sources: Vec::new(),
150        }
151    };
152
153    for seed_src in &seed_registry.sources {
154        let already_exists = existing_registry
155            .sources
156            .iter()
157            .any(|s| s.id == seed_src.id);
158        if !already_exists {
159            existing_registry.sources.push(seed_src.clone());
160        }
161        if let Some(existing) = existing_registry
162            .sources
163            .iter_mut()
164            .find(|s| s.id == seed_src.id && !s.mutable)
165        {
166            existing.name = seed_src.name.clone();
167            existing.url = seed_src.url.clone();
168            existing.source_type = seed_src.source_type.clone();
169            existing.parser = seed_src.parser.clone();
170            existing.region = seed_src.region.clone();
171            existing.language = seed_src.language.clone();
172            existing.domains = seed_src.domains.clone();
173        }
174    }
175
176    if let Ok(json) = serde_json::to_string_pretty(&existing_registry) {
177        write_seed_file(dir, "sources.json", &json);
178    }
179}
180
181pub fn load_rules(chat_root: &Path) -> Vec<PlanningRule> {
182    let path = prompts_dir(chat_root).join("rules.json");
183    if path.exists() {
184        if let Ok(content) = std::fs::read_to_string(&path) {
185            if let Ok(rules) = serde_json::from_str::<Vec<PlanningRule>>(&content) {
186                if !rules.is_empty() {
187                    return rules;
188                }
189            }
190        }
191    }
192    serde_json::from_str(SEED_RULES).unwrap_or_default()
193}
194
195pub fn load_sources(chat_root: &Path) -> SourceRegistry {
196    let path = prompts_dir(chat_root).join("sources.json");
197    if path.exists() {
198        if let Ok(content) = std::fs::read_to_string(&path) {
199            if let Ok(registry) = serde_json::from_str::<SourceRegistry>(&content) {
200                if !registry.sources.is_empty() {
201                    return registry;
202                }
203            }
204        }
205    }
206    serde_json::from_str(SEED_SOURCES).unwrap_or_else(|_| SourceRegistry {
207        version: 1,
208        sources: Vec::new(),
209    })
210}
211
212pub fn load_system_prompt(chat_root: &Path) -> String {
213    load_prompt_file(chat_root, "system.md", SEED_SYSTEM)
214}
215
216pub fn load_planning_template(chat_root: &Path) -> String {
217    load_prompt_file(chat_root, "planning.md", SEED_PLANNING)
218}
219
220pub fn load_execution_template(chat_root: &Path) -> String {
221    load_prompt_file(chat_root, "execution.md", SEED_EXECUTION)
222}
223
224pub fn load_examples(chat_root: &Path) -> String {
225    load_prompt_file(chat_root, "examples.md", SEED_EXAMPLES)
226}
227
228pub fn required_placeholders(name: &str) -> &'static [&'static str] {
229    match name {
230        "planning.md" => &[
231            "{{TODAY}}",
232            "{{RULES_SECTION}}",
233            "{{EXAMPLES_SECTION}}",
234            "{{OUTPUT_DIR}}",
235        ],
236        "execution.md" => &["{{TODAY}}", "{{SKILLS_LIST}}", "{{OUTPUT_DIR}}"],
237        "system.md" => &[],
238        "examples.md" => &[],
239        _ => &[],
240    }
241}
242
243pub fn validate_template(name: &str, content: &str) -> Vec<&'static str> {
244    required_placeholders(name)
245        .iter()
246        .filter(|p| !content.contains(**p))
247        .copied()
248        .collect()
249}
250
251pub fn load_prompt_file_with_project(
252    chat_root: &Path,
253    workspace: Option<&Path>,
254    name: &str,
255    fallback: &str,
256) -> String {
257    if let Some(ws) = workspace {
258        let project_path = ws.join(".skilllite").join("prompts").join(name);
259        if project_path.exists() {
260            if let Ok(content) = std::fs::read_to_string(&project_path) {
261                if !content.trim().is_empty() {
262                    let missing = validate_template(name, &content);
263                    if !missing.is_empty() {
264                        tracing::warn!(
265                            "Project template {} is missing placeholders {:?}",
266                            project_path.display(),
267                            missing
268                        );
269                    }
270                    return content;
271                }
272            }
273        }
274    }
275    load_prompt_file(chat_root, name, fallback)
276}
277
278fn load_prompt_file(chat_root: &Path, name: &str, fallback: &str) -> String {
279    let path = prompts_dir(chat_root).join(name);
280    if path.exists() {
281        if let Ok(content) = std::fs::read_to_string(&path) {
282            if !content.trim().is_empty() {
283                let missing = validate_template(name, &content);
284                if !missing.is_empty() {
285                    tracing::warn!(
286                        "Template {} is missing placeholders {:?}",
287                        path.display(),
288                        missing
289                    );
290                }
291                return content;
292            }
293        }
294    }
295    fallback.to_string()
296}
297
298#[cfg(test)]
299mod template_tests {
300    use super::{required_placeholders, validate_template};
301
302    #[test]
303    fn required_placeholders_planning_lists_four() {
304        let p = required_placeholders("planning.md");
305        assert_eq!(p.len(), 4);
306        assert!(p.contains(&"{{RULES_SECTION}}"));
307    }
308
309    #[test]
310    fn validate_template_reports_missing_placeholders() {
311        let missing = validate_template("planning.md", "no placeholders");
312        assert!(!missing.is_empty());
313        assert!(missing.contains(&"{{TODAY}}"));
314        let ok = validate_template(
315            "planning.md",
316            "{{TODAY}}{{RULES_SECTION}}{{EXAMPLES_SECTION}}{{OUTPUT_DIR}}",
317        );
318        assert!(ok.is_empty());
319    }
320
321    #[test]
322    fn validate_template_unknown_name_is_permissive() {
323        assert!(validate_template("other.md", "").is_empty());
324    }
325}