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