Skip to main content

lean_ctx/core/skillify/
candidate.rs

1//! Skill candidates mined from the project's session diary + knowledge facts.
2//!
3//! A candidate is a *distilled, potentially-reusable* pattern. We never invent
4//! content — every candidate is backed by a real diary entry or knowledge fact.
5
6use std::collections::HashMap;
7
8use crate::core::agents::{AgentDiary, DiaryEntryType};
9use crate::core::knowledge::ProjectKnowledge;
10
11/// A distilled pattern mined from a project's memory, before the gate decides
12/// whether it is durable enough to become a rule.
13#[derive(Debug, Clone)]
14pub struct SkillCandidate {
15    /// Normalized, prefix-free slug (e.g. `stop-before-build`).
16    pub slug: String,
17    /// One-line human title (becomes the rule `description`).
18    pub title: String,
19    /// The actionable body of the rule.
20    pub body: String,
21    /// Origin category (decision / insight / convention / …).
22    pub category: String,
23    /// How many sources reinforced this pattern (confirmations + mentions).
24    pub recurrence: u32,
25    /// Highest source confidence (0.0..=1.0).
26    pub confidence: f32,
27    /// Provenance: source session ids / agent ids.
28    pub sources: Vec<String>,
29}
30
31/// Knowledge categories that can become durable guidance. Progress and raw
32/// blockers are status, not reusable rules, so they are excluded.
33fn knowledge_category_is_skillable(cat: &str) -> bool {
34    matches!(
35        cat.to_ascii_lowercase().as_str(),
36        "decision" | "insight" | "gotcha" | "pattern" | "convention" | "preference"
37    )
38}
39
40/// Mine candidates from the project's knowledge facts and agent diaries,
41/// merged by slug (recurrence summed, strongest body + confidence kept).
42pub fn mine_candidates(project_root: &str) -> Vec<SkillCandidate> {
43    let mut by_slug: HashMap<String, SkillCandidate> = HashMap::new();
44
45    // Source 1 — curated knowledge facts (already deduped by the knowledge layer).
46    let knowledge = ProjectKnowledge::load_or_create(project_root);
47    for fact in &knowledge.facts {
48        if !knowledge_category_is_skillable(&fact.category) {
49            continue;
50        }
51        let title = title_from(&fact.key, &fact.value);
52        let slug = slugify(&title);
53        if slug.is_empty() {
54            continue;
55        }
56        let sources: Vec<String> = if fact.source_session.is_empty() {
57            Vec::new()
58        } else {
59            vec![fact.source_session.clone()]
60        };
61        merge_candidate(
62            &mut by_slug,
63            SkillCandidate {
64                slug,
65                title,
66                body: fact.value.trim().to_string(),
67                category: fact.category.to_ascii_lowercase(),
68                // Confirmations are explicit reinforcement; floor at 1.
69                recurrence: fact.confirmation_count.max(1),
70                confidence: fact.confidence,
71                sources,
72            },
73        );
74    }
75
76    // Source 2 — diary decisions / insights / discoveries across the project's agents.
77    for diary in AgentDiary::load_all_for_project(project_root) {
78        for entry in &diary.entries {
79            let category = match entry.entry_type {
80                DiaryEntryType::Decision => "decision",
81                DiaryEntryType::Insight => "insight",
82                DiaryEntryType::Discovery => "discovery",
83                DiaryEntryType::Progress | DiaryEntryType::Blocker => continue,
84            };
85            let body = entry.content.trim().to_string();
86            if body.is_empty() {
87                continue;
88            }
89            let title = title_from("", &body);
90            let slug = slugify(&title);
91            if slug.is_empty() {
92                continue;
93            }
94            merge_candidate(
95                &mut by_slug,
96                SkillCandidate {
97                    slug,
98                    title,
99                    body,
100                    category: category.to_string(),
101                    recurrence: 1,
102                    confidence: 0.6,
103                    sources: vec![diary.agent_id.clone()],
104                },
105            );
106        }
107    }
108
109    let mut out: Vec<SkillCandidate> = by_slug.into_values().collect();
110    out.sort_by(|a, b| {
111        b.recurrence
112            .cmp(&a.recurrence)
113            .then(b.confidence.total_cmp(&a.confidence))
114            .then(a.slug.cmp(&b.slug))
115    });
116    out
117}
118
119fn merge_candidate(map: &mut HashMap<String, SkillCandidate>, cand: SkillCandidate) {
120    if let Some(existing) = map.get_mut(&cand.slug) {
121        existing.recurrence = existing.recurrence.saturating_add(cand.recurrence);
122        if cand.confidence > existing.confidence {
123            existing.confidence = cand.confidence;
124        }
125        // Prefer the longer, more informative body.
126        if cand.body.len() > existing.body.len() {
127            existing.body = cand.body;
128        }
129        for s in cand.sources {
130            if !s.is_empty() && !existing.sources.contains(&s) {
131                existing.sources.push(s);
132            }
133        }
134    } else {
135        map.insert(cand.slug.clone(), cand);
136    }
137}
138
139/// A concise title: prefer a short fact key, else the first sentence of the body.
140fn title_from(key: &str, value: &str) -> String {
141    let k = key.trim();
142    if !k.is_empty() && k.chars().count() <= 80 {
143        return k.to_string();
144    }
145    first_sentence(value)
146}
147
148fn first_sentence(text: &str) -> String {
149    let t = text.trim();
150    let end = t.find(['.', '\n', '!', '?']).unwrap_or(t.len());
151    let candidate = t[..end].trim();
152    let s = if candidate.is_empty() { t } else { candidate };
153    truncate_chars(s, 80)
154}
155
156/// URL/file-safe slug: lowercase alphanumerics, single dashes, trimmed, capped.
157pub fn slugify(text: &str) -> String {
158    let mut slug = String::new();
159    let mut prev_dash = false;
160    for ch in text.trim().to_ascii_lowercase().chars() {
161        if ch.is_ascii_alphanumeric() {
162            slug.push(ch);
163            prev_dash = false;
164        } else if !slug.is_empty() && !prev_dash {
165            slug.push('-');
166            prev_dash = true;
167        }
168    }
169    let trimmed = slug.trim_matches('-');
170    truncate_chars(trimmed, 50).trim_matches('-').to_string()
171}
172
173fn truncate_chars(s: &str, max: usize) -> String {
174    if s.chars().count() <= max {
175        return s.to_string();
176    }
177    s.chars().take(max).collect()
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn slugify_normalizes() {
186        assert_eq!(slugify("Stop before Build!"), "stop-before-build");
187        assert_eq!(slugify("  multiple   spaces  "), "multiple-spaces");
188        assert_eq!(slugify("___weird@@@chars###"), "weird-chars");
189        assert_eq!(slugify(""), "");
190    }
191
192    #[test]
193    fn first_sentence_truncates() {
194        assert_eq!(
195            first_sentence("Use atomic writes. And more."),
196            "Use atomic writes"
197        );
198        assert!(first_sentence(&"x".repeat(200)).chars().count() <= 80);
199    }
200
201    #[test]
202    fn merge_sums_recurrence_and_keeps_strongest() {
203        let mut map = HashMap::new();
204        merge_candidate(
205            &mut map,
206            SkillCandidate {
207                slug: "s".into(),
208                title: "t".into(),
209                body: "short".into(),
210                category: "decision".into(),
211                recurrence: 1,
212                confidence: 0.5,
213                sources: vec!["a".into()],
214            },
215        );
216        merge_candidate(
217            &mut map,
218            SkillCandidate {
219                slug: "s".into(),
220                title: "t".into(),
221                body: "a much longer body".into(),
222                category: "decision".into(),
223                recurrence: 2,
224                confidence: 0.9,
225                sources: vec!["b".into()],
226            },
227        );
228        let c = &map["s"];
229        assert_eq!(c.recurrence, 3);
230        assert_eq!(c.confidence, 0.9);
231        assert_eq!(c.body, "a much longer body");
232        assert_eq!(c.sources, vec!["a".to_string(), "b".to_string()]);
233    }
234}