Skip to main content

lean_ctx/core/knowledge/
query.rs

1use chrono::{DateTime, Utc};
2
3use super::ranking::sort_fact_for_output;
4use super::types::{KnowledgeFact, ProjectKnowledge};
5
6impl ProjectKnowledge {
7    fn matching_indices(&self, term: &str, include_session: bool) -> Vec<usize> {
8        let Some(indices) = self.index.token_positions.get(term) else {
9            return if include_session {
10                self.index
11                    .session_token_positions
12                    .get(term)
13                    .cloned()
14                    .unwrap_or_default()
15            } else {
16                Vec::new()
17            };
18        };
19
20        if !include_session {
21            return indices.clone();
22        }
23
24        let Some(session_indices) = self.index.session_token_positions.get(term) else {
25            return indices.clone();
26        };
27        let mut merged = indices.clone();
28        merged.extend(
29            session_indices
30                .iter()
31                .copied()
32                .filter(|idx| indices.binary_search(idx).is_err()),
33        );
34        merged
35    }
36
37    pub fn recall(&self, query: &str) -> Vec<&KnowledgeFact> {
38        let q = query.to_lowercase();
39        let terms: Vec<&str> = q.split_whitespace().collect();
40        if terms.is_empty() {
41            return Vec::new();
42        }
43
44        let mut match_counts: std::collections::HashMap<usize, usize> =
45            std::collections::HashMap::new();
46        for term in &terms {
47            for idx in self.matching_indices(term, true) {
48                if self.facts[idx].is_current() {
49                    *match_counts.entry(idx).or_insert(0) += 1;
50                }
51            }
52        }
53
54        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
55            .into_iter()
56            .map(|(idx, count)| {
57                let f = &self.facts[idx];
58                let relevance = (count as f32 / terms.len() as f32) * f.quality_score();
59                (f, relevance)
60            })
61            .collect();
62
63        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
64        results.into_iter().map(|(f, _)| f).collect()
65    }
66
67    pub fn recall_by_category(&self, category: &str) -> Vec<&KnowledgeFact> {
68        self.index
69            .category_positions
70            .get(category)
71            .into_iter()
72            .flatten()
73            .filter_map(|&idx| self.facts.get(idx))
74            .filter(|f| f.is_current())
75            .collect()
76    }
77
78    pub fn recall_at_time(&self, query: &str, at: DateTime<Utc>) -> Vec<&KnowledgeFact> {
79        let q = query.to_lowercase();
80        let terms: Vec<&str> = q.split_whitespace().collect();
81        if terms.is_empty() {
82            return Vec::new();
83        }
84
85        let mut match_counts: std::collections::HashMap<usize, usize> =
86            std::collections::HashMap::new();
87        for term in &terms {
88            for idx in self.matching_indices(term, false) {
89                if self.facts[idx].was_valid_at(at) {
90                    *match_counts.entry(idx).or_insert(0) += 1;
91                }
92            }
93        }
94
95        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
96            .into_iter()
97            .map(|(idx, count)| {
98                let f = &self.facts[idx];
99                (f, count as f32 / terms.len() as f32)
100            })
101            .collect();
102
103        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
104        results.into_iter().map(|(f, _)| f).collect()
105    }
106
107    pub fn timeline(&self, category: &str) -> Vec<&KnowledgeFact> {
108        let mut facts: Vec<&KnowledgeFact> = self
109            .index
110            .category_positions
111            .get(category)
112            .into_iter()
113            .flatten()
114            .filter_map(|&idx| self.facts.get(idx))
115            .collect();
116        facts.sort_by_key(|x| x.created_at);
117        facts
118    }
119
120    pub fn list_rooms(&self) -> Vec<(String, usize)> {
121        let mut categories: std::collections::BTreeMap<String, usize> =
122            std::collections::BTreeMap::new();
123        for f in &self.facts {
124            if f.is_current() {
125                *categories.entry(f.category.clone()).or_insert(0) += 1;
126            }
127        }
128        categories.into_iter().collect()
129    }
130
131    pub fn recall_for_output(&mut self, query: &str, limit: usize) -> (Vec<KnowledgeFact>, usize) {
132        let q = query.to_lowercase();
133        let terms: Vec<&str> = q.split_whitespace().filter(|t| !t.is_empty()).collect();
134        if terms.is_empty() {
135            return (Vec::new(), 0);
136        }
137
138        let mut match_counts: std::collections::HashMap<usize, usize> =
139            std::collections::HashMap::new();
140        for term in &terms {
141            for idx in self.matching_indices(term, true) {
142                if self.facts[idx].is_current() {
143                    *match_counts.entry(idx).or_insert(0) += 1;
144                }
145            }
146        }
147
148        struct Scored {
149            idx: usize,
150            relevance: f32,
151        }
152
153        let mut scored: Vec<Scored> = match_counts
154            .into_iter()
155            .map(|(idx, count)| {
156                let f = &self.facts[idx];
157                let mut relevance = (count as f32 / terms.len() as f32) * f.confidence;
158                // Exact-match boost: an exact hit on the fact key (or category)
159                // should rank above incidental lexical matches (#2363). The +1.0
160                // dominates the [0,1] coverage*confidence base.
161                let key_lower = f.key.to_lowercase();
162                if key_lower == q {
163                    relevance += 1.0;
164                } else if f.category.to_lowercase() == q {
165                    relevance += 0.5;
166                }
167                // Observation tier (#802): a relevant synthesized entity-summary is
168                // orientation — lift it above incidental matches, but keep it below an
169                // exact key hit (+1.0) so a stale summary never buries a precise raw
170                // fact. Balanced, not absolute.
171                if f.is_synthesized_observation() {
172                    relevance += 0.4;
173                }
174                Scored { idx, relevance }
175            })
176            .collect();
177
178        scored.sort_by(|a, b| {
179            b.relevance
180                .partial_cmp(&a.relevance)
181                .unwrap_or(std::cmp::Ordering::Equal)
182                .then_with(|| sort_fact_for_output(&self.facts[a.idx], &self.facts[b.idx]))
183        });
184
185        let total = scored.len();
186        scored.truncate(limit);
187
188        let now = Utc::now();
189        let mut out: Vec<KnowledgeFact> = Vec::new();
190        for s in scored {
191            if let Some(f) = self.facts.get_mut(s.idx) {
192                f.retrieval_count = f.retrieval_count.saturating_add(1);
193                f.last_retrieved = Some(now);
194                out.push(f.clone());
195            }
196        }
197
198        (out, total)
199    }
200
201    pub fn recall_by_category_for_output(
202        &mut self,
203        category: &str,
204        limit: usize,
205    ) -> (Vec<KnowledgeFact>, usize) {
206        let mut idxs: Vec<usize> = self
207            .index
208            .category_positions
209            .get(category)
210            .into_iter()
211            .flatten()
212            .copied()
213            .filter(|&idx| self.facts[idx].is_current())
214            .collect();
215
216        // Within a category, synthesized observation summaries lead (#802) — a
217        // balanced tier ahead of the usual salience sort, never an absolute override.
218        idxs.sort_by(|a, b| {
219            let (fa, fb) = (&self.facts[*a], &self.facts[*b]);
220            fb.is_synthesized_observation()
221                .cmp(&fa.is_synthesized_observation())
222                .then_with(|| sort_fact_for_output(fa, fb))
223        });
224
225        let total = idxs.len();
226        idxs.truncate(limit);
227
228        let now = Utc::now();
229        let mut out = Vec::new();
230        for idx in idxs {
231            if let Some(f) = self.facts.get_mut(idx) {
232                f.retrieval_count = f.retrieval_count.saturating_add(1);
233                f.last_retrieved = Some(now);
234                out.push(f.clone());
235            }
236        }
237
238        (out, total)
239    }
240}