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 terms of an explicit knowledge query, lowercased and split exactly
148/// like the knowledge index splits facts (on whitespace, `-`, `_`, `/`,
149/// `.`), deduplicated in order. A whitespace-only split missed every
150/// hyphenated word: "Local-Read" was looked up as "local-read", a token the
151/// index never holds.
152fn query_terms(query: &str) -> Vec<String> {
153    let mut terms: Vec<String> = Vec::new();
154    for token in super::ranking::tokenize_lower(query) {
155        if !terms.contains(&token) {
156            terms.push(token);
157        }
158    }
159    terms
160}
161
162/// The task's content words, lowercased and split like the knowledge index
163/// splits facts, with surrounding punctuation trimmed ("validation." →
164/// "validation") and filler words dropped. Order-preserving and deduplicated.
165fn task_content_terms(task: &str) -> Vec<String> {
166    let mut terms: Vec<String> = Vec::new();
167    for token in super::ranking::tokenize_lower(task) {
168        let term = token.trim_matches(|c: char| !c.is_alphanumeric());
169        if term.is_empty() || TASK_FILLER_WORDS.contains(&term) {
170            continue;
171        }
172        if !terms.iter().any(|t| t == term) {
173            terms.push(term.to_string());
174        }
175    }
176    terms
177}
178
179impl ProjectKnowledge {
180    fn matching_indices(&self, term: &str, include_session: bool) -> Vec<usize> {
181        let Some(indices) = self.index.token_positions.get(term) else {
182            return if include_session {
183                self.index
184                    .session_token_positions
185                    .get(term)
186                    .cloned()
187                    .unwrap_or_default()
188            } else {
189                Vec::new()
190            };
191        };
192
193        if !include_session {
194            return indices.clone();
195        }
196
197        let Some(session_indices) = self.index.session_token_positions.get(term) else {
198            return indices.clone();
199        };
200        let mut merged = indices.clone();
201        merged.extend(
202            session_indices
203                .iter()
204                .copied()
205                .filter(|idx| indices.binary_search(idx).is_err()),
206        );
207        merged
208    }
209
210    pub fn recall(&self, query: &str) -> Vec<&KnowledgeFact> {
211        let terms = query_terms(query);
212        if terms.is_empty() {
213            return Vec::new();
214        }
215
216        let mut match_counts: std::collections::HashMap<usize, usize> =
217            std::collections::HashMap::new();
218        for term in &terms {
219            for idx in self.matching_indices(term, true) {
220                if self.facts[idx].is_current() {
221                    *match_counts.entry(idx).or_insert(0) += 1;
222                }
223            }
224        }
225
226        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
227            .into_iter()
228            .map(|(idx, count)| {
229                let f = &self.facts[idx];
230                let relevance = (count as f32 / terms.len() as f32) * f.quality_score();
231                (f, relevance)
232            })
233            .collect();
234
235        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
236        results.into_iter().map(|(f, _)| f).collect()
237    }
238
239    /// Recall for a free-text task description (overview, sub-agent briefing).
240    ///
241    /// Unlike [`Self::recall`], which serves an explicit knowledge query, a
242    /// task sentence is mostly framing: "Inspect alpha parser header
243    /// validation." shares only "inspect" with an unrelated fact about
244    /// deployments, and that single word used to be enough to present the
245    /// fact as relevant (#1832). Only the task's content words are matched,
246    /// tokenized the way the index is, so a task made of nothing but generic
247    /// words recalls nothing.
248    pub fn recall_for_task(&self, task: &str) -> Vec<&KnowledgeFact> {
249        let terms = task_content_terms(task);
250        if terms.is_empty() {
251            return Vec::new();
252        }
253        self.recall(&terms.join(" "))
254    }
255
256    pub fn recall_by_category(&self, category: &str) -> Vec<&KnowledgeFact> {
257        self.index
258            .category_positions
259            .get(category)
260            .into_iter()
261            .flatten()
262            .filter_map(|&idx| self.facts.get(idx))
263            .filter(|f| f.is_current())
264            .collect()
265    }
266
267    pub fn recall_at_time(&self, query: &str, at: DateTime<Utc>) -> Vec<&KnowledgeFact> {
268        let terms = query_terms(query);
269        if terms.is_empty() {
270            return Vec::new();
271        }
272
273        let mut match_counts: std::collections::HashMap<usize, usize> =
274            std::collections::HashMap::new();
275        for term in &terms {
276            for idx in self.matching_indices(term, false) {
277                if self.facts[idx].was_valid_at(at) {
278                    *match_counts.entry(idx).or_insert(0) += 1;
279                }
280            }
281        }
282
283        let mut results: Vec<(&KnowledgeFact, f32)> = match_counts
284            .into_iter()
285            .map(|(idx, count)| {
286                let f = &self.facts[idx];
287                (f, count as f32 / terms.len() as f32)
288            })
289            .collect();
290
291        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
292        results.into_iter().map(|(f, _)| f).collect()
293    }
294
295    pub fn timeline(&self, category: &str) -> Vec<&KnowledgeFact> {
296        let mut facts: Vec<&KnowledgeFact> = self
297            .index
298            .category_positions
299            .get(category)
300            .into_iter()
301            .flatten()
302            .filter_map(|&idx| self.facts.get(idx))
303            .collect();
304        facts.sort_by_key(|x| x.created_at);
305        facts
306    }
307
308    pub fn list_rooms(&self) -> Vec<(String, usize)> {
309        let mut categories: std::collections::BTreeMap<String, usize> =
310            std::collections::BTreeMap::new();
311        for f in &self.facts {
312            if f.is_current() {
313                *categories.entry(f.category.clone()).or_insert(0) += 1;
314            }
315        }
316        categories.into_iter().collect()
317    }
318
319    pub fn recall_for_output(&mut self, query: &str, limit: usize) -> (Vec<KnowledgeFact>, usize) {
320        let q = query.to_lowercase();
321        let terms = query_terms(query);
322        if terms.is_empty() {
323            return (Vec::new(), 0);
324        }
325
326        let mut match_counts: std::collections::HashMap<usize, usize> =
327            std::collections::HashMap::new();
328        for term in &terms {
329            for idx in self.matching_indices(term, true) {
330                if self.facts[idx].is_current() {
331                    *match_counts.entry(idx).or_insert(0) += 1;
332                }
333            }
334        }
335
336        struct Scored {
337            idx: usize,
338            relevance: f32,
339        }
340
341        let mut scored: Vec<Scored> = match_counts
342            .into_iter()
343            .map(|(idx, count)| {
344                let f = &self.facts[idx];
345                let mut relevance = (count as f32 / terms.len() as f32) * f.confidence;
346                // Exact-match boost: an exact hit on the fact key (or category)
347                // should rank above incidental lexical matches (#2363). The +1.0
348                // dominates the [0,1] coverage*confidence base.
349                let key_lower = f.key.to_lowercase();
350                if key_lower == q {
351                    relevance += 1.0;
352                } else if f.category.to_lowercase() == q {
353                    relevance += 0.5;
354                }
355                // Observation tier (#802): a relevant synthesized entity-summary is
356                // orientation — lift it above incidental matches, but keep it below an
357                // exact key hit (+1.0) so a stale summary never buries a precise raw
358                // fact. Balanced, not absolute.
359                if f.is_synthesized_observation() {
360                    relevance += 0.4;
361                }
362                Scored { idx, relevance }
363            })
364            .collect();
365
366        scored.sort_by(|a, b| {
367            b.relevance
368                .partial_cmp(&a.relevance)
369                .unwrap_or(std::cmp::Ordering::Equal)
370                .then_with(|| sort_fact_for_output(&self.facts[a.idx], &self.facts[b.idx]))
371        });
372
373        let total = scored.len();
374        scored.truncate(limit);
375
376        let now = Utc::now();
377        let mut out: Vec<KnowledgeFact> = Vec::new();
378        for s in scored {
379            if let Some(f) = self.facts.get_mut(s.idx) {
380                f.retrieval_count = f.retrieval_count.saturating_add(1);
381                f.last_retrieved = Some(now);
382                out.push(f.clone());
383            }
384        }
385
386        (out, total)
387    }
388
389    pub fn recall_by_category_for_output(
390        &mut self,
391        category: &str,
392        limit: usize,
393    ) -> (Vec<KnowledgeFact>, usize) {
394        let mut idxs: Vec<usize> = self
395            .index
396            .category_positions
397            .get(category)
398            .into_iter()
399            .flatten()
400            .copied()
401            .filter(|&idx| self.facts[idx].is_current())
402            .collect();
403
404        // Within a category, synthesized observation summaries lead (#802) — a
405        // balanced tier ahead of the usual salience sort, never an absolute override.
406        idxs.sort_by(|a, b| {
407            let (fa, fb) = (&self.facts[*a], &self.facts[*b]);
408            fb.is_synthesized_observation()
409                .cmp(&fa.is_synthesized_observation())
410                .then_with(|| sort_fact_for_output(fa, fb))
411        });
412
413        let total = idxs.len();
414        idxs.truncate(limit);
415
416        let now = Utc::now();
417        let mut out = Vec::new();
418        for idx in idxs {
419            if let Some(f) = self.facts.get_mut(idx) {
420                f.retrieval_count = f.retrieval_count.saturating_add(1);
421                f.last_retrieved = Some(now);
422                out.push(f.clone());
423            }
424        }
425
426        (out, total)
427    }
428}