Skip to main content

lean_ctx/core/knowledge/
import_export.rs

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