Skip to main content

lean_ctx/core/knowledge/
query.rs

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