Skip to main content

lean_ctx/core/knowledge/
query.rs

1use chrono::{DateTime, Utc};
2use lean_ctx_protocol::knowledge::{ClassificationLevel, KnowledgeObjectV1};
3
4use super::ranking::sort_fact_for_output;
5use super::types::{KnowledgeFact, ProjectKnowledge};
6
7/// Filters understood by portable Knowledge Hub stores.
8#[derive(Debug, Clone, Default)]
9pub struct KnowledgeQuery {
10    pub source: Option<String>,
11    pub classification: Option<ClassificationLevel>,
12    pub valid_at: Option<DateTime<Utc>>,
13    pub tags: Vec<String>,
14}
15
16impl KnowledgeQuery {
17    /// Match objects that are valid and not superseded at `timestamp`.
18    pub fn valid_at(timestamp: DateTime<Utc>) -> Self {
19        Self {
20            valid_at: Some(timestamp),
21            ..Self::default()
22        }
23    }
24
25    /// Return whether an object satisfies every configured filter.
26    pub fn matches(&self, object: &KnowledgeObjectV1) -> bool {
27        if self.source.as_ref().is_some_and(|source| {
28            object
29                .source_ref
30                .as_ref()
31                .is_none_or(|reference| reference.uri != *source)
32        }) {
33            return false;
34        }
35        if self.classification.is_some_and(|level| {
36            object
37                .classification
38                .as_ref()
39                .is_none_or(|classification| classification.level != level)
40        }) {
41            return false;
42        }
43        if let Some(timestamp) = self.valid_at {
44            let Some(validity) = &object.validity else {
45                return false;
46            };
47            if validity.superseded_by.is_some() {
48                return false;
49            }
50            let Ok(valid_from) = DateTime::parse_from_rfc3339(&validity.valid_from) else {
51                return false;
52            };
53            if timestamp < valid_from.with_timezone(&Utc) {
54                return false;
55            }
56            if let Some(valid_until) = &validity.valid_until {
57                let Ok(valid_until) = DateTime::parse_from_rfc3339(valid_until) else {
58                    return false;
59                };
60                if timestamp > valid_until.with_timezone(&Utc) {
61                    return false;
62                }
63            }
64        }
65        let object_tags = object
66            .extra
67            .get("tags")
68            .and_then(serde_json::Value::as_array);
69        self.tags.iter().all(|tag| {
70            object_tags.is_some_and(|tags| tags.iter().any(|value| value.as_str() == Some(tag)))
71        })
72    }
73}
74
75/// Words that frame a task rather than name its subject: function words and
76/// the action verbs nearly every task starts with.
77const TASK_FILLER_WORDS: &[&str] = &[
78    "a",
79    "an",
80    "and",
81    "are",
82    "as",
83    "at",
84    "be",
85    "by",
86    "for",
87    "from",
88    "how",
89    "i",
90    "in",
91    "into",
92    "is",
93    "it",
94    "its",
95    "me",
96    "my",
97    "of",
98    "on",
99    "or",
100    "our",
101    "please",
102    "so",
103    "that",
104    "the",
105    "then",
106    "this",
107    "to",
108    "up",
109    "we",
110    "what",
111    "when",
112    "where",
113    "which",
114    "why",
115    "with",
116    // Generic task verbs. Words that are just as often the subject ("build",
117    // "test", "run", "support") stay content words.
118    "add",
119    "analyse",
120    "analyze",
121    "change",
122    "check",
123    "create",
124    "debug",
125    "explain",
126    "explore",
127    "find",
128    "fix",
129    "implement",
130    "improve",
131    "inspect",
132    "investigate",
133    "look",
134    "make",
135    "refactor",
136    "remove",
137    "rename",
138    "review",
139    "see",
140    "show",
141    "understand",
142    "update",
143    "verify",
144    "write",
145];
146
147/// The task's content words, lowercased and split like the knowledge index
148/// splits facts, with surrounding punctuation trimmed ("validation." →
149/// "validation") and filler words dropped. Order-preserving and deduplicated.
150fn task_content_terms(task: &str) -> Vec<String> {
151    let mut terms: Vec<String> = Vec::new();
152    for token in super::ranking::tokenize_lower(task) {
153        let term = token.trim_matches(|c: char| !c.is_alphanumeric());
154        if term.is_empty() || TASK_FILLER_WORDS.contains(&term) {
155            continue;
156        }
157        if !terms.iter().any(|t| t == term) {
158            terms.push(term.to_string());
159        }
160    }
161    terms
162}
163
164impl ProjectKnowledge {
165    fn matching_indices(&self, term: &str, include_session: bool) -> Vec<usize> {
166        let Some(indices) = self.index.token_positions.get(term) else {
167            return if include_session {
168                self.index
169                    .session_token_positions
170                    .get(term)
171                    .cloned()
172                    .unwrap_or_default()
173            } else {
174                Vec::new()
175            };
176        };
177
178        if !include_session {
179            return indices.clone();
180        }
181
182        let Some(session_indices) = self.index.session_token_positions.get(term) else {
183            return indices.clone();
184        };
185        let mut merged = indices.clone();
186        merged.extend(
187            session_indices
188                .iter()
189                .copied()
190                .filter(|idx| indices.binary_search(idx).is_err()),
191        );
192        merged
193    }
194
195    pub fn recall(&self, query: &str) -> Vec<&KnowledgeFact> {
196        let q = query.to_lowercase();
197        let terms: Vec<&str> = q.split_whitespace().collect();
198        if terms.is_empty() {
199            return Vec::new();
200        }
201
202        let mut match_counts: std::collections::HashMap<usize, usize> =
203            std::collections::HashMap::new();
204        for term in &terms {
205            for idx in self.matching_indices(term, true) {
206                if self.facts[idx].is_current() {
207                    *match_counts.entry(idx).or_insert(0) += 1;
208                }
209            }
210        }
211
212        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
213            .into_iter()
214            .map(|(idx, count)| {
215                let f = &self.facts[idx];
216                let relevance = (count as f32 / terms.len() as f32) * f.quality_score();
217                (f, relevance)
218            })
219            .collect();
220
221        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
222        results.into_iter().map(|(f, _)| f).collect()
223    }
224
225    /// Recall for a free-text task description (overview, sub-agent briefing).
226    ///
227    /// Unlike [`Self::recall`], which serves an explicit knowledge query, a
228    /// task sentence is mostly framing: "Inspect alpha parser header
229    /// validation." shares only "inspect" with an unrelated fact about
230    /// deployments, and that single word used to be enough to present the
231    /// fact as relevant (#1832). Only the task's content words are matched,
232    /// tokenized the way the index is, so a task made of nothing but generic
233    /// words recalls nothing.
234    pub fn recall_for_task(&self, task: &str) -> Vec<&KnowledgeFact> {
235        let terms = task_content_terms(task);
236        if terms.is_empty() {
237            return Vec::new();
238        }
239        self.recall(&terms.join(" "))
240    }
241
242    pub fn recall_by_category(&self, category: &str) -> Vec<&KnowledgeFact> {
243        self.index
244            .category_positions
245            .get(category)
246            .into_iter()
247            .flatten()
248            .filter_map(|&idx| self.facts.get(idx))
249            .filter(|f| f.is_current())
250            .collect()
251    }
252
253    pub fn recall_at_time(&self, query: &str, at: DateTime<Utc>) -> Vec<&KnowledgeFact> {
254        let q = query.to_lowercase();
255        let terms: Vec<&str> = q.split_whitespace().collect();
256        if terms.is_empty() {
257            return Vec::new();
258        }
259
260        let mut match_counts: std::collections::HashMap<usize, usize> =
261            std::collections::HashMap::new();
262        for term in &terms {
263            for idx in self.matching_indices(term, false) {
264                if self.facts[idx].was_valid_at(at) {
265                    *match_counts.entry(idx).or_insert(0) += 1;
266                }
267            }
268        }
269
270        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
271            .into_iter()
272            .map(|(idx, count)| {
273                let f = &self.facts[idx];
274                (f, count as f32 / terms.len() as f32)
275            })
276            .collect();
277
278        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
279        results.into_iter().map(|(f, _)| f).collect()
280    }
281
282    pub fn timeline(&self, category: &str) -> Vec<&KnowledgeFact> {
283        let mut facts: Vec<&KnowledgeFact> = self
284            .index
285            .category_positions
286            .get(category)
287            .into_iter()
288            .flatten()
289            .filter_map(|&idx| self.facts.get(idx))
290            .collect();
291        facts.sort_by_key(|x| x.created_at);
292        facts
293    }
294
295    pub fn list_rooms(&self) -> Vec<(String, usize)> {
296        let mut categories: std::collections::BTreeMap<String, usize> =
297            std::collections::BTreeMap::new();
298        for f in &self.facts {
299            if f.is_current() {
300                *categories.entry(f.category.clone()).or_insert(0) += 1;
301            }
302        }
303        categories.into_iter().collect()
304    }
305
306    pub fn recall_for_output(&mut self, query: &str, limit: usize) -> (Vec<KnowledgeFact>, usize) {
307        let q = query.to_lowercase();
308        let terms: Vec<&str> = q.split_whitespace().filter(|t| !t.is_empty()).collect();
309        if terms.is_empty() {
310            return (Vec::new(), 0);
311        }
312
313        let mut match_counts: std::collections::HashMap<usize, usize> =
314            std::collections::HashMap::new();
315        for term in &terms {
316            for idx in self.matching_indices(term, true) {
317                if self.facts[idx].is_current() {
318                    *match_counts.entry(idx).or_insert(0) += 1;
319                }
320            }
321        }
322
323        struct Scored {
324            idx: usize,
325            relevance: f32,
326        }
327
328        let mut scored: Vec<Scored> = match_counts
329            .into_iter()
330            .map(|(idx, count)| {
331                let f = &self.facts[idx];
332                let mut relevance = (count as f32 / terms.len() as f32) * f.confidence;
333                // Exact-match boost: an exact hit on the fact key (or category)
334                // should rank above incidental lexical matches (#2363). The +1.0
335                // dominates the [0,1] coverage*confidence base.
336                let key_lower = f.key.to_lowercase();
337                if key_lower == q {
338                    relevance += 1.0;
339                } else if f.category.to_lowercase() == q {
340                    relevance += 0.5;
341                }
342                // Observation tier (#802): a relevant synthesized entity-summary is
343                // orientation — lift it above incidental matches, but keep it below an
344                // exact key hit (+1.0) so a stale summary never buries a precise raw
345                // fact. Balanced, not absolute.
346                if f.is_synthesized_observation() {
347                    relevance += 0.4;
348                }
349                Scored { idx, relevance }
350            })
351            .collect();
352
353        scored.sort_by(|a, b| {
354            b.relevance
355                .partial_cmp(&a.relevance)
356                .unwrap_or(std::cmp::Ordering::Equal)
357                .then_with(|| sort_fact_for_output(&self.facts[a.idx], &self.facts[b.idx]))
358        });
359
360        let total = scored.len();
361        scored.truncate(limit);
362
363        let now = Utc::now();
364        let mut out: Vec<KnowledgeFact> = Vec::new();
365        for s in scored {
366            if let Some(f) = self.facts.get_mut(s.idx) {
367                f.retrieval_count = f.retrieval_count.saturating_add(1);
368                f.last_retrieved = Some(now);
369                out.push(f.clone());
370            }
371        }
372
373        (out, total)
374    }
375
376    pub fn recall_by_category_for_output(
377        &mut self,
378        category: &str,
379        limit: usize,
380    ) -> (Vec<KnowledgeFact>, usize) {
381        let mut idxs: Vec<usize> = self
382            .index
383            .category_positions
384            .get(category)
385            .into_iter()
386            .flatten()
387            .copied()
388            .filter(|&idx| self.facts[idx].is_current())
389            .collect();
390
391        // Within a category, synthesized observation summaries lead (#802) — a
392        // balanced tier ahead of the usual salience sort, never an absolute override.
393        idxs.sort_by(|a, b| {
394            let (fa, fb) = (&self.facts[*a], &self.facts[*b]);
395            fb.is_synthesized_observation()
396                .cmp(&fa.is_synthesized_observation())
397                .then_with(|| sort_fact_for_output(fa, fb))
398        });
399
400        let total = idxs.len();
401        idxs.truncate(limit);
402
403        let now = Utc::now();
404        let mut out = Vec::new();
405        for idx in idxs {
406            if let Some(f) = self.facts.get_mut(idx) {
407                f.retrieval_count = f.retrieval_count.saturating_add(1);
408                f.last_retrieved = Some(now);
409                out.push(f.clone());
410            }
411        }
412
413        (out, total)
414    }
415}