Skip to main content

lean_ctx/core/knowledge/
import_export.rs

1use chrono::Utc;
2use serde::{Deserialize, Serialize};
3
4use super::types::{KnowledgeArchetype, KnowledgeFact, ProjectKnowledge};
5use crate::core::memory_boundary::FactPrivacy;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ImportMerge {
9    Replace,
10    Append,
11    SkipExisting,
12}
13
14impl ImportMerge {
15    pub fn parse(s: &str) -> Option<Self> {
16        match s.to_lowercase().as_str() {
17            "replace" => Some(Self::Replace),
18            "append" => Some(Self::Append),
19            "skip-existing" | "skip_existing" | "skip" => Some(Self::SkipExisting),
20            _ => None,
21        }
22    }
23}
24
25#[derive(Debug, Clone)]
26pub struct ImportResult {
27    pub added: u32,
28    pub skipped: u32,
29    pub replaced: u32,
30    /// `category/key` of every skipped fact whose incoming value differs from
31    /// the kept local one (counted in `skipped` too). A skip-existing merge
32    /// never overwrites; these are the conflicts it must make visible.
33    pub conflicts: Vec<String>,
34}
35
36/// Community-compatible simple fact format for import/export interop.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SimpleFactEntry {
39    pub category: String,
40    pub key: String,
41    pub value: String,
42    #[serde(default)]
43    pub confidence: Option<f32>,
44    #[serde(default)]
45    pub source: Option<String>,
46    #[serde(default)]
47    pub timestamp: Option<String>,
48}
49
50/// Parse import data: tries native `ProjectKnowledge` first, then simple `[{...}]` array.
51pub fn parse_import_data(data: &str) -> Result<Vec<KnowledgeFact>, String> {
52    if let Ok(mut pk) = serde_json::from_str::<ProjectKnowledge>(data) {
53        for fact in &mut pk.facts {
54            fact.origin = super::source_view::imported_origin(&fact.origin);
55        }
56        return Ok(pk.facts);
57    }
58
59    if let Ok(entries) = serde_json::from_str::<Vec<SimpleFactEntry>>(data) {
60        let now = Utc::now();
61        let facts = entries
62            .into_iter()
63            .map(|e| KnowledgeFact {
64                anchors: Vec::new(),
65                review: None,
66                capture: Default::default(),
67                origin: super::FactOrigin::Unverified,
68                archetype: KnowledgeArchetype::infer_from_category(&e.category),
69                sensitivity: crate::core::sensitivity::classify_content(&e.value),
70                category: e.category,
71                key: e.key,
72                value: e.value,
73                source_session: e.source.unwrap_or_else(|| "import".to_string()),
74                confidence: e.confidence.unwrap_or(0.8),
75                created_at: now,
76                last_confirmed: now,
77                retrieval_count: 0,
78                last_retrieved: None,
79                valid_from: Some(now),
80                valid_until: None,
81                supersedes: None,
82                confirmation_count: 1,
83                feedback_up: 0,
84                feedback_down: 0,
85                last_feedback: None,
86                privacy: FactPrivacy::default(),
87                imported_from: None,
88                fidelity: None,
89                revision_count: 0,
90                status: None,
91                validity: None,
92            })
93            .collect();
94        return Ok(facts);
95    }
96
97    let mut facts = Vec::new();
98    for line in data.lines() {
99        let line = line.trim();
100        if line.is_empty() {
101            continue;
102        }
103        if let Ok(entry) = serde_json::from_str::<SimpleFactEntry>(line) {
104            let now = Utc::now();
105            facts.push(KnowledgeFact {
106                anchors: Vec::new(),
107                review: None,
108                capture: Default::default(),
109                origin: super::FactOrigin::Unverified,
110                archetype: KnowledgeArchetype::infer_from_category(&entry.category),
111                sensitivity: crate::core::sensitivity::classify_content(&entry.value),
112                category: entry.category,
113                key: entry.key,
114                value: entry.value,
115                source_session: entry.source.unwrap_or_else(|| "import".to_string()),
116                confidence: entry.confidence.unwrap_or(0.8),
117                created_at: now,
118                last_confirmed: now,
119                retrieval_count: 0,
120                last_retrieved: None,
121                valid_from: Some(now),
122                valid_until: None,
123                supersedes: None,
124                confirmation_count: 1,
125                feedback_up: 0,
126                feedback_down: 0,
127                last_feedback: None,
128                privacy: FactPrivacy::default(),
129                imported_from: None,
130                fidelity: None,
131                revision_count: 0,
132                status: None,
133                validity: None,
134            });
135        } else {
136            return Err(format!(
137                "Invalid JSONL line: {}",
138                &line[..line.len().min(80)]
139            ));
140        }
141    }
142
143    if facts.is_empty() {
144        return Err("No facts found. Expected: native JSON, simple JSON array, or JSONL.".into());
145    }
146    Ok(facts)
147}
148
149fn imported_fact(source: &KnowledgeFact, session_id: &str) -> KnowledgeFact {
150    let now = Utc::now();
151    KnowledgeFact {
152        anchors: Vec::new(),
153        review: None,
154        capture: Default::default(),
155        origin: super::source_view::imported_origin(&source.origin),
156        category: source.category.clone(),
157        key: source.key.clone(),
158        value: source.value.clone(),
159        source_session: session_id.to_string(),
160        confidence: source.confidence,
161        created_at: now,
162        last_confirmed: now,
163        retrieval_count: 0,
164        last_retrieved: None,
165        valid_from: Some(now),
166        valid_until: None,
167        supersedes: None,
168        confirmation_count: 1,
169        feedback_up: 0,
170        feedback_down: 0,
171        last_feedback: None,
172        privacy: source.privacy,
173        sensitivity: source.sensitivity,
174        imported_from: source.imported_from.clone(),
175        archetype: source.archetype.clone(),
176        fidelity: None,
177        revision_count: 0,
178        status: None,
179        validity: source.validity,
180    }
181}
182
183impl ProjectKnowledge {
184    /// Import facts from an external source with a configurable merge strategy.
185    /// Returns (added, skipped, replaced) counts.
186    pub fn import_facts(
187        &mut self,
188        incoming: Vec<KnowledgeFact>,
189        merge: ImportMerge,
190        session_id: &str,
191    ) -> ImportResult {
192        let mut added = 0u32;
193        let mut skipped = 0u32;
194        let mut replaced = 0u32;
195        let mut conflicts = Vec::new();
196
197        for fact in incoming {
198            let existing = self
199                .facts
200                .iter()
201                .position(|f| f.category == fact.category && f.key == fact.key && f.is_current());
202
203            match (&merge, existing) {
204                (ImportMerge::SkipExisting, Some(idx)) => {
205                    if self.facts[idx].value != fact.value {
206                        conflicts.push(format!("{}/{}", fact.category, fact.key));
207                    }
208                    skipped += 1;
209                }
210                (ImportMerge::Replace, Some(idx)) => {
211                    self.facts[idx].valid_until = Some(Utc::now());
212                    self.facts.push(imported_fact(&fact, session_id));
213                    replaced += 1;
214                }
215                (ImportMerge::Append, Some(_)) | (_, None) => {
216                    self.facts.push(imported_fact(&fact, session_id));
217                    added += 1;
218                }
219            }
220        }
221
222        if added > 0 || replaced > 0 {
223            self.updated_at = Utc::now();
224            self.rebuild_index();
225        }
226
227        ImportResult {
228            added,
229            skipped,
230            replaced,
231            conflicts,
232        }
233    }
234
235    /// Export current facts as a simple JSON array (community-compatible schema).
236    pub fn export_simple(&self) -> Vec<SimpleFactEntry> {
237        self.facts
238            .iter()
239            .filter(|f| f.is_current())
240            .map(|f| SimpleFactEntry {
241                category: f.category.clone(),
242                key: f.key.clone(),
243                value: f.value.clone(),
244                confidence: Some(f.confidence),
245                source: Some(f.source_session.clone()),
246                timestamp: Some(f.created_at.to_rfc3339()),
247            })
248            .collect()
249    }
250}