use std::collections::HashMap;
use crate::Engine;
use crate::auth::ScopedSpace;
use crate::error::{Result, SconeError};
#[derive(Debug, Default)]
pub struct ImportReport {
pub episodes: usize,
pub deduplicated: usize,
pub facts: usize,
pub aliases: usize,
}
impl Engine {
pub fn export_jsonl(&self, space: &ScopedSpace) -> Result<String> {
let mut out = String::new();
let mut stmt = self.conn.prepare(
"SELECT kind, content, source, created_at, hash
FROM episodes WHERE space_id = ?1 ORDER BY id",
)?;
let rows = stmt.query_map([space.id()], |r| {
Ok(serde_json::json!({
"type": "episode",
"kind": r.get::<_, String>(0)?,
"content": r.get::<_, String>(1)?,
"source": r.get::<_, Option<String>>(2)?,
"created_at": r.get::<_, String>(3)?,
"hash": r.get::<_, String>(4)?,
}))
})?;
for row in rows {
out.push_str(&row?.to_string());
out.push('\n');
}
let mut stmt = self.conn.prepare(
"SELECT a.alias, en.canonical FROM entity_aliases a
JOIN entities en ON en.id = a.entity_id ORDER BY a.alias",
)?;
let rows = stmt.query_map([], |r| {
Ok(serde_json::json!({
"type": "alias",
"alias": r.get::<_, String>(0)?,
"canonical": r.get::<_, String>(1)?,
}))
})?;
for row in rows {
out.push_str(&row?.to_string());
out.push('\n');
}
let mut stmt = self.conn.prepare(
"SELECT en.canonical, f.predicate, f.object, f.confidence, f.valid_from,
f.valid_until, f.status, f.status_reason,
(SELECT json_group_array(e.hash) FROM fact_provenance fp
JOIN episodes e ON e.id = fp.episode_id WHERE fp.fact_id = f.id)
FROM facts f JOIN entities en ON en.id = f.subject_entity
WHERE f.space_id = ?1 ORDER BY f.id",
)?;
let rows = stmt.query_map([space.id()], |r| {
let provenance: String = r.get(8)?;
Ok(serde_json::json!({
"type": "fact",
"subject": r.get::<_, String>(0)?,
"predicate": r.get::<_, String>(1)?,
"object": r.get::<_, String>(2)?,
"confidence": r.get::<_, f64>(3)?,
"valid_from": r.get::<_, String>(4)?,
"valid_until": r.get::<_, Option<String>>(5)?,
"status": r.get::<_, String>(6)?,
"status_reason": r.get::<_, Option<String>>(7)?,
"provenance_hashes": serde_json::from_str::<serde_json::Value>(&provenance)
.unwrap_or_else(|_| serde_json::json!([])),
}))
})?;
for row in rows {
out.push_str(&row?.to_string());
out.push('\n');
}
Ok(out)
}
pub fn import_jsonl(&mut self, space: &ScopedSpace, data: &str) -> Result<ImportReport> {
let mut report = ImportReport::default();
let mut records = Vec::new();
for (n, line) in data.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let value: serde_json::Value = serde_json::from_str(line)
.map_err(|e| SconeError::InvalidInput(format!("line {}: not JSON: {e}", n + 1)))?;
records.push(value);
}
let mut hash_to_id: HashMap<String, i64> = HashMap::new();
for record in &records {
if record["type"] == "episode" {
let content = record["content"].as_str().unwrap_or_default();
let kind = record["kind"].as_str().unwrap_or("note");
let outcome = self.import_episode(
space,
kind,
content,
record["source"].as_str(),
record["created_at"].as_str(),
)?;
let (id, fresh) = outcome;
if fresh {
report.episodes += 1;
} else {
report.deduplicated += 1;
}
hash_to_id.insert(blake3::hash(content.as_bytes()).to_hex().to_string(), id);
}
}
for record in &records {
match record["type"].as_str() {
Some("alias") => {
let (Some(alias), Some(canonical)) =
(record["alias"].as_str(), record["canonical"].as_str())
else {
continue;
};
let existed: i64 = self.conn.query_row(
"SELECT count(*) FROM entity_aliases WHERE alias = ?1",
[alias],
|r| r.get(0),
)?;
self.add_entity_alias(alias, canonical)?;
if existed == 0 {
report.aliases += 1;
}
}
Some("fact") if self.import_fact(space, record, &hash_to_id)? => {
report.facts += 1;
}
_ => {}
}
}
Ok(report)
}
}
impl Engine {
fn import_fact(
&mut self,
space: &ScopedSpace,
record: &serde_json::Value,
hash_to_id: &HashMap<String, i64>,
) -> Result<bool> {
let field = |key: &str| -> Result<&str> {
record[key]
.as_str()
.ok_or_else(|| SconeError::InvalidInput(format!("fact record missing {key}")))
};
let subject = field("subject")?;
let predicate = field("predicate")?;
let object = field("object")?;
let valid_from = field("valid_from")?;
let status = field("status")?;
if !["active", "closed", "expired"].contains(&status) {
return Err(SconeError::InvalidInput(format!(
"fact status {status:?} is not one of active/closed/expired"
)));
}
let confidence = record["confidence"].as_f64().unwrap_or(0.5);
let valid_until = record["valid_until"].as_str();
let status_reason = record["status_reason"].as_str();
let mut episode_ids = Vec::new();
if let Some(hashes) = record["provenance_hashes"].as_array() {
for h in hashes.iter().filter_map(|h| h.as_str()) {
let id = match hash_to_id.get(h) {
Some(id) => *id,
None => match self.conn.query_row(
"SELECT id FROM episodes WHERE space_id = ?1 AND hash = ?2",
rusqlite::params![space.id(), h],
|r| r.get::<_, i64>(0),
) {
Ok(id) => id,
Err(rusqlite::Error::QueryReturnedNoRows) => continue,
Err(e) => return Err(SconeError::Db(e)),
},
};
episode_ids.push(id);
}
}
if episode_ids.is_empty() {
return Err(SconeError::InvalidInput(format!(
"fact ({subject} {predicate} {object}) references no importable \
episode; import episodes first (I4)"
)));
}
let tx = self.conn.transaction()?;
let entity_id: i64 = {
let canonical = subject.trim().to_lowercase();
tx.execute(
"INSERT OR IGNORE INTO entities (canonical) VALUES (?1)",
[&canonical],
)?;
tx.query_row(
"SELECT id FROM entities WHERE canonical = ?1",
[&canonical],
|r| r.get(0),
)?
};
let exists: i64 = tx.query_row(
"SELECT count(*) FROM facts
WHERE space_id = ?1 AND subject_entity = ?2 AND predicate = ?3
AND object = ?4 AND valid_from = ?5 AND status = ?6",
rusqlite::params![space.id(), entity_id, predicate, object, valid_from, status],
|r| r.get(0),
)?;
if exists > 0 {
tx.commit()?;
return Ok(false);
}
tx.execute(
"INSERT INTO facts (space_id, subject_entity, predicate, object, confidence,
valid_from, valid_until, status, status_reason)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
space.id(),
entity_id,
predicate,
object,
confidence,
valid_from,
valid_until,
status,
status_reason
],
)?;
let fact_id = tx.last_insert_rowid();
for episode_id in episode_ids {
tx.execute(
"INSERT OR IGNORE INTO fact_provenance (fact_id, episode_id) VALUES (?1, ?2)",
rusqlite::params![fact_id, episode_id],
)?;
}
tx.commit()?;
Ok(true)
}
}