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