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