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            })
79            .collect();
80        return Ok(facts);
81    }
82
83    let mut facts = Vec::new();
84    for line in data.lines() {
85        let line = line.trim();
86        if line.is_empty() {
87            continue;
88        }
89        if let Ok(entry) = serde_json::from_str::<SimpleFactEntry>(line) {
90            let now = Utc::now();
91            facts.push(KnowledgeFact {
92                category: entry.category,
93                key: entry.key,
94                value: entry.value,
95                source_session: entry.source.unwrap_or_else(|| "import".to_string()),
96                confidence: entry.confidence.unwrap_or(0.8),
97                created_at: now,
98                last_confirmed: now,
99                retrieval_count: 0,
100                last_retrieved: None,
101                valid_from: Some(now),
102                valid_until: None,
103                supersedes: None,
104                confirmation_count: 1,
105                feedback_up: 0,
106                feedback_down: 0,
107                last_feedback: None,
108                privacy: FactPrivacy::default(),
109                imported_from: None,
110                archetype: KnowledgeArchetype::default(),
111                fidelity: None,
112            });
113        } else {
114            return Err(format!(
115                "Invalid JSONL line: {}",
116                &line[..line.len().min(80)]
117            ));
118        }
119    }
120
121    if facts.is_empty() {
122        return Err("No facts found. Expected: native JSON, simple JSON array, or JSONL.".into());
123    }
124    Ok(facts)
125}
126
127fn imported_fact(source: &KnowledgeFact, session_id: &str) -> KnowledgeFact {
128    let now = Utc::now();
129    KnowledgeFact {
130        category: source.category.clone(),
131        key: source.key.clone(),
132        value: source.value.clone(),
133        source_session: session_id.to_string(),
134        confidence: source.confidence,
135        created_at: now,
136        last_confirmed: now,
137        retrieval_count: 0,
138        last_retrieved: None,
139        valid_from: Some(now),
140        valid_until: None,
141        supersedes: None,
142        confirmation_count: 1,
143        feedback_up: 0,
144        feedback_down: 0,
145        last_feedback: None,
146        privacy: source.privacy,
147        imported_from: source.imported_from.clone(),
148        archetype: source.archetype.clone(),
149        fidelity: None,
150    }
151}
152
153impl ProjectKnowledge {
154    /// Import facts from an external source with a configurable merge strategy.
155    /// Returns (added, skipped, replaced) counts.
156    pub fn import_facts(
157        &mut self,
158        incoming: Vec<KnowledgeFact>,
159        merge: ImportMerge,
160        session_id: &str,
161        policy: &MemoryPolicy,
162    ) -> ImportResult {
163        let mut added = 0u32;
164        let mut skipped = 0u32;
165        let mut replaced = 0u32;
166
167        for fact in incoming {
168            let existing = self
169                .facts
170                .iter()
171                .position(|f| f.category == fact.category && f.key == fact.key && f.is_current());
172
173            match (&merge, existing) {
174                (ImportMerge::SkipExisting, Some(_)) => {
175                    skipped += 1;
176                }
177                (ImportMerge::Replace, Some(idx)) => {
178                    self.facts[idx].valid_until = Some(Utc::now());
179                    self.facts.push(imported_fact(&fact, session_id));
180                    replaced += 1;
181                }
182                (ImportMerge::Append, Some(_)) | (_, None) => {
183                    self.facts.push(imported_fact(&fact, session_id));
184                    added += 1;
185                }
186            }
187        }
188
189        if added > 0 || replaced > 0 {
190            self.updated_at = Utc::now();
191            if self.facts.len() > policy.knowledge.max_facts.saturating_mul(2) {
192                let _ = self.run_memory_lifecycle(policy);
193            }
194        }
195
196        ImportResult {
197            added,
198            skipped,
199            replaced,
200        }
201    }
202
203    /// Export current facts as a simple JSON array (community-compatible schema).
204    pub fn export_simple(&self) -> Vec<SimpleFactEntry> {
205        self.facts
206            .iter()
207            .filter(|f| f.is_current())
208            .map(|f| SimpleFactEntry {
209                category: f.category.clone(),
210                key: f.key.clone(),
211                value: f.value.clone(),
212                confidence: Some(f.confidence),
213                source: Some(f.source_session.clone()),
214                timestamp: Some(f.created_at.to_rfc3339()),
215            })
216            .collect()
217    }
218}