lean_ctx/core/knowledge/
ranking.rs1use chrono::Utc;
2
3use super::types::{JudgedPair, KnowledgeFact, KnowledgeIndex};
4
5pub(super) fn confidence_stars(confidence: f32) -> &'static str {
6 if confidence >= 0.95 {
7 "★★★★★"
8 } else if confidence >= 0.85 {
9 "★★★★"
10 } else if confidence >= 0.7 {
11 "★★★"
12 } else if confidence >= 0.5 {
13 "★★"
14 } else {
15 "★"
16 }
17}
18
19pub fn string_similarity(a: &str, b: &str) -> f32 {
21 let a_lower = a.to_lowercase();
22 let b_lower = b.to_lowercase();
23 let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
24 let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
25
26 if a_words.is_empty() && b_words.is_empty() {
27 return 1.0;
28 }
29
30 let intersection = a_words.intersection(&b_words).count();
31 let union = a_words.union(&b_words).count();
32
33 if union == 0 {
34 return 0.0;
35 }
36
37 intersection as f32 / union as f32
38}
39
40pub fn sort_fact_for_output(a: &KnowledgeFact, b: &KnowledgeFact) -> std::cmp::Ordering {
41 salience_score(b)
48 .cmp(&salience_score(a))
49 .then_with(|| {
50 b.quality_score()
51 .partial_cmp(&a.quality_score())
52 .unwrap_or(std::cmp::Ordering::Equal)
53 })
54 .then_with(|| {
55 b.confidence
56 .partial_cmp(&a.confidence)
57 .unwrap_or(std::cmp::Ordering::Equal)
58 })
59 .then_with(|| b.confirmation_count.cmp(&a.confirmation_count))
60 .then_with(|| b.retrieval_count.cmp(&a.retrieval_count))
61 .then_with(|| b.last_retrieved.cmp(&a.last_retrieved))
62 .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
63 .then_with(|| a.category.cmp(&b.category))
64 .then_with(|| a.key.cmp(&b.key))
65 .then_with(|| a.value.cmp(&b.value))
66}
67
68fn salience_score(f: &KnowledgeFact) -> u32 {
76 let cat = f.category.to_lowercase();
77 let base: u32 = match cat.as_str() {
78 "decision" => 70,
79 "gotcha" => 75,
80 "architecture" | "arch" => 60,
81 "security" => 65,
82 "testing" | "tests" | "deployment" | "deploy" => 55,
83 "conventions" | "convention" => 45,
84 "finding" => 40,
85 _ => 30,
86 };
87
88 let quality_bonus = (f.quality_score() * 60.0) as u32;
89
90 let recency_bonus = f.last_retrieved.map_or(0u32, |t| {
91 let days = Utc::now().signed_duration_since(t).num_days();
92 if days <= 7 {
93 10u32
94 } else if days <= 30 {
95 5u32
96 } else {
97 0u32
98 }
99 });
100
101 let archetype_bonus = f.archetype.salience_bonus();
102
103 let fidelity_bonus = f
104 .fidelity
105 .as_ref()
106 .map_or(0u32, |fi| (fi.structural * 10.0) as u32);
107
108 base + quality_bonus + recency_bonus + archetype_bonus + fidelity_bonus
109}
110
111pub(super) fn hash_project_root(root: &str) -> String {
112 crate::core::project_hash::hash_project_root(root)
113}
114
115pub(super) fn tokenize_lower(s: &str) -> impl Iterator<Item = String> + '_ {
116 s.to_lowercase()
117 .split(|c: char| c.is_whitespace() || c == '-' || c == '_' || c == '/' || c == '.')
118 .filter(|t| !t.is_empty())
119 .map(String::from)
120 .collect::<Vec<_>>()
121 .into_iter()
122}
123
124pub(super) fn build_knowledge_index(facts: &[KnowledgeFact]) -> KnowledgeIndex {
125 let mut index = KnowledgeIndex::default();
126 for (i, f) in facts.iter().enumerate() {
127 for token in tokenize_lower(&f.category) {
128 index.token_positions.entry(token).or_default().push(i);
129 }
130 for token in tokenize_lower(&f.key) {
131 index.token_positions.entry(token).or_default().push(i);
132 }
133 for token in tokenize_lower(&f.value) {
134 index.token_positions.entry(token).or_default().push(i);
135 }
136 for token in tokenize_lower(&f.source_session) {
137 index
138 .session_token_positions
139 .entry(token)
140 .or_default()
141 .push(i);
142 }
143 index
144 .category_positions
145 .entry(f.category.clone())
146 .or_default()
147 .push(i);
148 }
149 for indices in index.token_positions.values_mut() {
150 indices.sort_unstable();
151 indices.dedup();
152 }
153 for indices in index.session_token_positions.values_mut() {
154 indices.sort_unstable();
155 indices.dedup();
156 }
157 index
158}
159
160#[derive(Debug, Clone)]
161pub struct SimilarFact {
162 pub category: String,
163 pub key: String,
164 pub value_preview: String,
165 pub similarity: f32,
166}
167
168pub fn find_cross_key_similar(
169 new_category: &str,
170 new_key: &str,
171 new_value: &str,
172 all_facts: &[KnowledgeFact],
173 judged_pairs: &[JudgedPair],
174 limit: usize,
175) -> Vec<SimilarFact> {
176 let composite_key = format!("{new_category}/{new_key}");
177 let mut results: Vec<SimilarFact> = Vec::new();
178
179 for f in all_facts {
180 if !f.is_current() {
181 continue;
182 }
183 let other_key = format!("{}/{}", f.category, f.key);
184 if other_key == composite_key {
185 continue;
186 }
187
188 let already_judged = judged_pairs.iter().any(|jp| {
189 (jp.key_a == composite_key && jp.key_b == other_key)
190 || (jp.key_a == other_key && jp.key_b == composite_key)
191 });
192 if already_judged {
193 continue;
194 }
195
196 let sim = string_similarity(new_value, &f.value);
197 if sim > 0.35 {
198 let preview = if f.value.chars().count() > 60 {
200 format!("{}...", f.value.chars().take(57).collect::<String>())
201 } else {
202 f.value.clone()
203 };
204 results.push(SimilarFact {
205 category: f.category.clone(),
206 key: f.key.clone(),
207 value_preview: preview,
208 similarity: sim,
209 });
210 }
211 }
212
213 results.sort_by(|a, b| {
214 b.similarity
215 .partial_cmp(&a.similarity)
216 .unwrap_or(std::cmp::Ordering::Equal)
217 });
218 results.truncate(limit);
219 results
220}
221
222pub fn fact_version_id_v1(f: &KnowledgeFact) -> String {
224 use md5::{Digest, Md5};
225 let mut hasher = Md5::new();
226 hasher.update(f.category.as_bytes());
227 hasher.update(b"\n");
228 hasher.update(f.key.as_bytes());
229 hasher.update(b"\n");
230 hasher.update(f.value.as_bytes());
231 hasher.update(b"\n");
232 hasher.update(f.source_session.as_bytes());
233 hasher.update(b"\n");
234 hasher.update(f.created_at.to_rfc3339().as_bytes());
235 crate::core::agent_identity::hex_encode(&hasher.finalize())
236}