1use std::collections::HashMap;
9
10use crate::Engine;
11use crate::auth::ScopedSpace;
12use crate::error::{Result, SconeError};
13
14#[derive(Debug, Default)]
15pub struct ImportReport {
16 pub episodes: usize,
17 pub deduplicated: usize,
18 pub facts: usize,
19 pub aliases: usize,
20}
21
22impl Engine {
23 pub fn export_jsonl(&self, space: &ScopedSpace) -> Result<String> {
26 let mut out = String::new();
27 let mut stmt = self.conn.prepare(
28 "SELECT kind, content, source, created_at, hash
29 FROM episodes WHERE space_id = ?1 ORDER BY id",
30 )?;
31 let rows = stmt.query_map([space.id()], |r| {
32 Ok(serde_json::json!({
33 "type": "episode",
34 "kind": r.get::<_, String>(0)?,
35 "content": r.get::<_, String>(1)?,
36 "source": r.get::<_, Option<String>>(2)?,
37 "created_at": r.get::<_, String>(3)?,
38 "hash": r.get::<_, String>(4)?,
39 }))
40 })?;
41 for row in rows {
42 out.push_str(&row?.to_string());
43 out.push('\n');
44 }
45 let mut stmt = self.conn.prepare(
46 "SELECT a.alias, en.canonical FROM entity_aliases a
47 JOIN entities en ON en.id = a.entity_id ORDER BY a.alias",
48 )?;
49 let rows = stmt.query_map([], |r| {
50 Ok(serde_json::json!({
51 "type": "alias",
52 "alias": r.get::<_, String>(0)?,
53 "canonical": r.get::<_, String>(1)?,
54 }))
55 })?;
56 for row in rows {
57 out.push_str(&row?.to_string());
58 out.push('\n');
59 }
60 let mut stmt = self.conn.prepare(
61 "SELECT en.canonical, f.predicate, f.object, f.confidence, f.valid_from,
62 f.valid_until, f.status, f.status_reason,
63 (SELECT json_group_array(e.hash) FROM fact_provenance fp
64 JOIN episodes e ON e.id = fp.episode_id WHERE fp.fact_id = f.id)
65 FROM facts f JOIN entities en ON en.id = f.subject_entity
66 WHERE f.space_id = ?1 ORDER BY f.id",
67 )?;
68 let rows = stmt.query_map([space.id()], |r| {
69 let provenance: String = r.get(8)?;
70 Ok(serde_json::json!({
71 "type": "fact",
72 "subject": r.get::<_, String>(0)?,
73 "predicate": r.get::<_, String>(1)?,
74 "object": r.get::<_, String>(2)?,
75 "confidence": r.get::<_, f64>(3)?,
76 "valid_from": r.get::<_, String>(4)?,
77 "valid_until": r.get::<_, Option<String>>(5)?,
78 "status": r.get::<_, String>(6)?,
79 "status_reason": r.get::<_, Option<String>>(7)?,
80 "provenance_hashes": serde_json::from_str::<serde_json::Value>(&provenance)
81 .unwrap_or_else(|_| serde_json::json!([])),
82 }))
83 })?;
84 for row in rows {
85 out.push_str(&row?.to_string());
86 out.push('\n');
87 }
88 Ok(out)
89 }
90
91 pub fn import_jsonl(&mut self, space: &ScopedSpace, data: &str) -> Result<ImportReport> {
93 let mut report = ImportReport::default();
94 let mut records = Vec::new();
95 for (n, line) in data.lines().enumerate() {
96 if line.trim().is_empty() {
97 continue;
98 }
99 let value: serde_json::Value = serde_json::from_str(line)
100 .map_err(|e| SconeError::InvalidInput(format!("line {}: not JSON: {e}", n + 1)))?;
101 records.push(value);
102 }
103 let mut hash_to_id: HashMap<String, i64> = HashMap::new();
105 for record in &records {
106 if record["type"] == "episode" {
107 let content = record["content"].as_str().unwrap_or_default();
108 let kind = record["kind"].as_str().unwrap_or("note");
109 let outcome = self.import_episode(
110 space,
111 kind,
112 content,
113 record["source"].as_str(),
114 record["created_at"].as_str(),
115 )?;
116 let (id, fresh) = outcome;
117 if fresh {
118 report.episodes += 1;
119 } else {
120 report.deduplicated += 1;
121 }
122 hash_to_id.insert(blake3::hash(content.as_bytes()).to_hex().to_string(), id);
123 }
124 }
125 for record in &records {
127 match record["type"].as_str() {
128 Some("alias") => {
129 let (Some(alias), Some(canonical)) =
130 (record["alias"].as_str(), record["canonical"].as_str())
131 else {
132 continue;
133 };
134 let existed: i64 = self.conn.query_row(
135 "SELECT count(*) FROM entity_aliases WHERE alias = ?1",
136 [alias],
137 |r| r.get(0),
138 )?;
139 self.add_entity_alias(alias, canonical)?;
140 if existed == 0 {
141 report.aliases += 1;
142 }
143 }
144 Some("fact") if self.import_fact(space, record, &hash_to_id)? => {
145 report.facts += 1;
146 }
147 _ => {}
148 }
149 }
150 Ok(report)
151 }
152}
153
154impl Engine {
155 fn import_fact(
157 &mut self,
158 space: &ScopedSpace,
159 record: &serde_json::Value,
160 hash_to_id: &HashMap<String, i64>,
161 ) -> Result<bool> {
162 let field = |key: &str| -> Result<&str> {
163 record[key]
164 .as_str()
165 .ok_or_else(|| SconeError::InvalidInput(format!("fact record missing {key}")))
166 };
167 let subject = field("subject")?;
168 let predicate = field("predicate")?;
169 let object = field("object")?;
170 let valid_from = field("valid_from")?;
171 let status = field("status")?;
172 if !["active", "closed", "expired"].contains(&status) {
173 return Err(SconeError::InvalidInput(format!(
174 "fact status {status:?} is not one of active/closed/expired"
175 )));
176 }
177 let confidence = record["confidence"].as_f64().unwrap_or(0.5);
178 let valid_until = record["valid_until"].as_str();
179 let status_reason = record["status_reason"].as_str();
180
181 let mut episode_ids = Vec::new();
184 if let Some(hashes) = record["provenance_hashes"].as_array() {
185 for h in hashes.iter().filter_map(|h| h.as_str()) {
186 let id = match hash_to_id.get(h) {
187 Some(id) => *id,
188 None => match self.conn.query_row(
189 "SELECT id FROM episodes WHERE space_id = ?1 AND hash = ?2",
190 rusqlite::params![space.id(), h],
191 |r| r.get::<_, i64>(0),
192 ) {
193 Ok(id) => id,
194 Err(rusqlite::Error::QueryReturnedNoRows) => continue,
195 Err(e) => return Err(SconeError::Db(e)),
196 },
197 };
198 episode_ids.push(id);
199 }
200 }
201 if episode_ids.is_empty() {
202 return Err(SconeError::InvalidInput(format!(
203 "fact ({subject} {predicate} {object}) references no importable \
204 episode; import episodes first (I4)"
205 )));
206 }
207
208 let tx = self.conn.transaction()?;
209 let entity_id: i64 = {
210 let canonical = subject.trim().to_lowercase();
211 tx.execute(
212 "INSERT OR IGNORE INTO entities (canonical) VALUES (?1)",
213 [&canonical],
214 )?;
215 tx.query_row(
216 "SELECT id FROM entities WHERE canonical = ?1",
217 [&canonical],
218 |r| r.get(0),
219 )?
220 };
221 let exists: i64 = tx.query_row(
222 "SELECT count(*) FROM facts
223 WHERE space_id = ?1 AND subject_entity = ?2 AND predicate = ?3
224 AND object = ?4 AND valid_from = ?5 AND status = ?6",
225 rusqlite::params![space.id(), entity_id, predicate, object, valid_from, status],
226 |r| r.get(0),
227 )?;
228 if exists > 0 {
229 tx.commit()?;
230 return Ok(false);
231 }
232 tx.execute(
233 "INSERT INTO facts (space_id, subject_entity, predicate, object, confidence,
234 valid_from, valid_until, status, status_reason)
235 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
236 rusqlite::params![
237 space.id(),
238 entity_id,
239 predicate,
240 object,
241 confidence,
242 valid_from,
243 valid_until,
244 status,
245 status_reason
246 ],
247 )?;
248 let fact_id = tx.last_insert_rowid();
249 for episode_id in episode_ids {
250 tx.execute(
251 "INSERT OR IGNORE INTO fact_provenance (fact_id, episode_id) VALUES (?1, ?2)",
252 rusqlite::params![fact_id, episode_id],
253 )?;
254 }
255 tx.commit()?;
256 Ok(true)
257 }
258}