lean_ctx/core/skillify/
candidate.rs1use std::collections::HashMap;
7
8use crate::core::agents::{AgentDiary, DiaryEntryType};
9use crate::core::knowledge::ProjectKnowledge;
10
11#[derive(Debug, Clone)]
14pub struct SkillCandidate {
15 pub slug: String,
17 pub title: String,
19 pub body: String,
21 pub category: String,
23 pub recurrence: u32,
25 pub confidence: f32,
27 pub sources: Vec<String>,
29}
30
31fn 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
40pub fn mine_candidates(project_root: &str) -> Vec<SkillCandidate> {
43 let mut by_slug: HashMap<String, SkillCandidate> = HashMap::new();
44
45 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 recurrence: fact.confirmation_count.max(1),
70 confidence: fact.confidence,
71 sources,
72 },
73 );
74 }
75
76 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 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
139fn 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
156pub 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}