Skip to main content

lean_ctx/core/
knowledge_provider_extract.rs

1//! Knowledge auto-extraction from provider data (issues, PRs, DB schemas).
2//!
3//! Converts external `ContentChunk`s into knowledge facts that flow into the
4//! `ProjectKnowledge` system. This implements the "Sleep Replay" pattern from
5//! hippocampal consolidation: raw episodic data is transformed into semantic
6//! long-term knowledge.
7//!
8//! Extraction rules:
9//!   - Issues with labels → `known_bugs`, `known_features`, `known_issues`
10//!   - PRs → `recent_changes`, `active_branches`
11//!   - DB schemas → `data_model` facts
12//!   - Wiki pages → `documentation` facts
13
14use crate::core::content_chunk::ContentChunk;
15
16/// A knowledge fact extracted from provider data, ready for `ProjectKnowledge.remember()`.
17#[derive(Debug, Clone)]
18pub struct ExtractedFact {
19    pub origin: crate::core::knowledge::FactOrigin,
20    pub category: String,
21    pub key: String,
22    pub value: String,
23    pub confidence: f32,
24}
25
26/// Extract knowledge facts from a set of ContentChunks.
27pub fn extract_facts(chunks: &[ContentChunk]) -> Vec<ExtractedFact> {
28    let mut facts = Vec::new();
29
30    for chunk in chunks {
31        if !chunk.is_external() {
32            continue;
33        }
34
35        let provider = chunk.provider_id().unwrap_or("unknown");
36        // An emspace space is the curated source itself: its pages reach
37        // agents as `ctx_read` hints and provider search. Copying them into
38        // facts would duplicate the space without its freshness (a page goes
39        // stale there, its fact copy would not), and `documented_files` keyed
40        // by path kept only the last page of several documenting one file.
41        if provider == "emspace" {
42            continue;
43        }
44        let start = facts.len();
45        match chunk.kind {
46            crate::core::bm25_index::ChunkKind::Issue
47            | crate::core::bm25_index::ChunkKind::Ticket => {
48                extract_issue_facts(chunk, provider, &mut facts);
49            }
50            crate::core::bm25_index::ChunkKind::PullRequest => {
51                extract_pr_facts(chunk, provider, &mut facts);
52            }
53            crate::core::bm25_index::ChunkKind::WikiPage => {
54                extract_wiki_facts(chunk, provider, &mut facts);
55            }
56            crate::core::bm25_index::ChunkKind::DbSchema => {
57                extract_db_facts(chunk, provider, &mut facts);
58            }
59            _ => {}
60        }
61        for fact in &mut facts[start..] {
62            fact.origin = chunk
63                .origin
64                .clone()
65                .map_or(crate::core::knowledge::FactOrigin::Unverified, |origin| {
66                    crate::core::knowledge::FactOrigin::Provider(Box::new(origin))
67                });
68        }
69    }
70
71    facts
72}
73
74fn extract_issue_facts(chunk: &ContentChunk, provider: &str, facts: &mut Vec<ExtractedFact>) {
75    let state = chunk
76        .metadata
77        .as_ref()
78        .and_then(|m| m["state"].as_str())
79        .unwrap_or("unknown");
80
81    let labels: Vec<&str> = chunk
82        .metadata
83        .as_ref()
84        .and_then(|m| m["labels"].as_array())
85        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
86        .unwrap_or_default();
87
88    let category = if labels.iter().any(|l| {
89        let lower = l.to_lowercase();
90        lower.contains("bug") || lower.contains("defect")
91    }) {
92        "known_bugs"
93    } else if labels.iter().any(|l| {
94        let lower = l.to_lowercase();
95        lower.contains("feature") || lower.contains("enhancement")
96    }) {
97        "known_features"
98    } else {
99        "known_issues"
100    };
101
102    let issue_id = chunk
103        .file_path
104        .rsplit('/')
105        .next()
106        .unwrap_or(&chunk.file_path);
107
108    facts.push(ExtractedFact {
109        origin: crate::core::knowledge::FactOrigin::Unverified,
110        category: category.to_string(),
111        key: format!("{provider}#{issue_id}"),
112        value: format!("{} [{}]", chunk.symbol_name, state),
113        confidence: if state == "open" { 0.9 } else { 0.7 },
114    });
115
116    for ref_path in &chunk.references {
117        facts.push(ExtractedFact {
118            origin: crate::core::knowledge::FactOrigin::Unverified,
119            category: "file_mentions".to_string(),
120            key: ref_path.clone(),
121            value: format!(
122                "Referenced in {} {provider}#{issue_id}: {}",
123                category, chunk.symbol_name
124            ),
125            confidence: 0.85,
126        });
127    }
128}
129
130fn extract_pr_facts(chunk: &ContentChunk, provider: &str, facts: &mut Vec<ExtractedFact>) {
131    let state = chunk
132        .metadata
133        .as_ref()
134        .and_then(|m| m["state"].as_str())
135        .unwrap_or("unknown");
136
137    let pr_id = chunk
138        .file_path
139        .rsplit('/')
140        .next()
141        .unwrap_or(&chunk.file_path);
142
143    facts.push(ExtractedFact {
144        origin: crate::core::knowledge::FactOrigin::Unverified,
145        category: "recent_changes".to_string(),
146        key: format!("{provider}#PR{pr_id}"),
147        value: format!("{} [{}]", chunk.symbol_name, state),
148        confidence: if state == "open" { 0.95 } else { 0.8 },
149    });
150
151    for ref_path in &chunk.references {
152        facts.push(ExtractedFact {
153            origin: crate::core::knowledge::FactOrigin::Unverified,
154            category: "changed_files".to_string(),
155            key: ref_path.clone(),
156            value: format!("Changed in PR {provider}#{pr_id}: {}", chunk.symbol_name),
157            confidence: 0.9,
158        });
159    }
160}
161
162fn extract_wiki_facts(chunk: &ContentChunk, provider: &str, facts: &mut Vec<ExtractedFact>) {
163    let page_id = chunk
164        .file_path
165        .rsplit('/')
166        .next()
167        .unwrap_or(&chunk.file_path);
168
169    facts.push(ExtractedFact {
170        origin: crate::core::knowledge::FactOrigin::Unverified,
171        category: "documentation".to_string(),
172        key: format!("{provider}#{page_id}"),
173        value: chunk.symbol_name.clone(),
174        confidence: 0.85,
175    });
176
177    for ref_path in &chunk.references {
178        facts.push(ExtractedFact {
179            origin: crate::core::knowledge::FactOrigin::Unverified,
180            category: "documented_files".to_string(),
181            key: ref_path.clone(),
182            value: format!("Documented in {provider}#{page_id}: {}", chunk.symbol_name),
183            confidence: 0.8,
184        });
185    }
186}
187
188fn extract_db_facts(chunk: &ContentChunk, provider: &str, facts: &mut Vec<ExtractedFact>) {
189    let table_id = chunk
190        .file_path
191        .rsplit('/')
192        .next()
193        .unwrap_or(&chunk.file_path);
194
195    facts.push(ExtractedFact {
196        origin: crate::core::knowledge::FactOrigin::Unverified,
197        category: "data_model".to_string(),
198        key: format!("{provider}#{table_id}"),
199        value: chunk.symbol_name.clone(),
200        confidence: 0.95,
201    });
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::core::bm25_index::ChunkKind;
208    use crate::core::content_chunk::ContentChunk;
209
210    fn issue_with_labels(id: &str, title: &str, labels: &[&str], refs: Vec<&str>) -> ContentChunk {
211        ContentChunk::from_provider(
212            "github",
213            "issues",
214            id,
215            title,
216            ChunkKind::Issue,
217            format!("Body of {title}"),
218            refs.into_iter().map(String::from).collect(),
219            Some(serde_json::json!({
220                "state": "open",
221                "labels": labels,
222            })),
223        )
224    }
225
226    #[test]
227    fn bug_label_creates_known_bugs_fact() {
228        let chunk = issue_with_labels("42", "Auth crash", &["bug", "p1"], vec!["src/auth.rs"]);
229        let facts = extract_facts(&[chunk]);
230
231        let bug_fact = facts.iter().find(|f| f.category == "known_bugs");
232        assert!(bug_fact.is_some());
233        assert!(bug_fact.unwrap().key.contains("42"));
234        assert!(bug_fact.unwrap().value.contains("Auth crash"));
235        assert!(bug_fact.unwrap().value.contains("[open]"));
236    }
237
238    #[test]
239    fn feature_label_creates_known_features_fact() {
240        let chunk = issue_with_labels("10", "Dark mode", &["enhancement"], vec![]);
241        let facts = extract_facts(&[chunk]);
242
243        assert!(facts.iter().any(|f| f.category == "known_features"));
244    }
245
246    #[test]
247    fn generic_issue_creates_known_issues_fact() {
248        let chunk = issue_with_labels("5", "Question about API", &["question"], vec![]);
249        let facts = extract_facts(&[chunk]);
250
251        assert!(facts.iter().any(|f| f.category == "known_issues"));
252    }
253
254    #[test]
255    fn issue_with_refs_creates_file_mentions() {
256        let chunk = issue_with_labels(
257            "42",
258            "Auth crash",
259            &["bug"],
260            vec!["src/auth.rs", "src/db.rs"],
261        );
262        let facts = extract_facts(&[chunk]);
263
264        let mentions: Vec<_> = facts
265            .iter()
266            .filter(|f| f.category == "file_mentions")
267            .collect();
268        assert_eq!(mentions.len(), 2);
269        assert!(mentions.iter().any(|f| f.key == "src/auth.rs"));
270        assert!(mentions.iter().any(|f| f.key == "src/db.rs"));
271    }
272
273    #[test]
274    fn pr_creates_recent_changes_and_changed_files() {
275        let chunk = ContentChunk::from_provider(
276            "github",
277            "pull_requests",
278            "100",
279            "Fix auth token expiry",
280            ChunkKind::PullRequest,
281            "Fixes token expiry".into(),
282            vec!["src/auth.rs".into()],
283            Some(serde_json::json!({"state": "open"})),
284        );
285
286        let facts = extract_facts(&[chunk]);
287        assert!(facts.iter().any(|f| f.category == "recent_changes"));
288        assert!(
289            facts
290                .iter()
291                .any(|f| f.category == "changed_files" && f.key == "src/auth.rs")
292        );
293    }
294
295    #[test]
296    fn wiki_creates_documentation_facts() {
297        let chunk = ContentChunk::from_provider(
298            "confluence",
299            "wikis",
300            "auth-guide",
301            "Authentication Guide",
302            ChunkKind::WikiPage,
303            "How auth works".into(),
304            vec!["src/auth/mod.rs".into()],
305            None,
306        );
307
308        let facts = extract_facts(&[chunk]);
309        assert!(facts.iter().any(|f| f.category == "documentation"));
310        assert!(facts.iter().any(|f| f.category == "documented_files"));
311    }
312
313    #[test]
314    fn emspace_pages_stay_in_the_space_and_are_not_copied_into_facts() {
315        let page = ContentChunk::from_provider(
316            "emspace",
317            "pages",
318            "core/patterns",
319            "Core: Shell Output Patterns",
320            ChunkKind::WikiPage,
321            "Compresses CLI output".into(),
322            vec!["rust/src/core/patterns/mod.rs".into()],
323            None,
324        );
325        assert!(extract_facts(&[page]).is_empty());
326    }
327
328    #[test]
329    fn db_creates_data_model_facts() {
330        let chunk = ContentChunk::from_provider(
331            "postgres",
332            "schemas",
333            "users",
334            "public.users",
335            ChunkKind::DbSchema,
336            "CREATE TABLE users (id serial, email varchar)".into(),
337            vec![],
338            None,
339        );
340
341        let facts = extract_facts(&[chunk]);
342        assert_eq!(facts.len(), 1);
343        assert_eq!(facts[0].category, "data_model");
344        assert_eq!(facts[0].confidence, 0.95);
345    }
346
347    #[test]
348    fn code_chunks_are_skipped() {
349        let chunk = ContentChunk::from(crate::core::bm25_index::CodeChunk {
350            file_path: "src/main.rs".into(),
351            symbol_name: "main".into(),
352            kind: ChunkKind::Function,
353            start_line: 1,
354            end_line: 5,
355            content: "fn main() {}".into(),
356            tokens: vec![],
357            token_count: 0,
358        });
359
360        let facts = extract_facts(&[chunk]);
361        assert!(facts.is_empty());
362    }
363
364    #[test]
365    fn closed_issues_have_lower_confidence() {
366        let chunk = ContentChunk::from_provider(
367            "github",
368            "issues",
369            "99",
370            "Old bug",
371            ChunkKind::Issue,
372            "Fixed".into(),
373            vec![],
374            Some(serde_json::json!({"state": "closed", "labels": ["bug"]})),
375        );
376
377        let facts = extract_facts(&[chunk]);
378        let fact = facts.iter().find(|f| f.category == "known_bugs").unwrap();
379        assert!(fact.confidence < 0.9);
380    }
381}