Skip to main content

aimem_core/
knowledge.rs

1//! Knowledge graph — temporal entity-relationship graph.
2//!
3//! Mirrors Python's knowledge_graph.py.  All stored in Turso (triples + entities tables).
4//!
5//! Usage:
6//! ```rust,no_run
7//! use aimem_core::{AimemDb, KnowledgeGraph};
8//! # async fn run() -> anyhow::Result<()> {
9//! let db = AimemDb::memory().await?;
10//! let kg = KnowledgeGraph::new(db);
11//! kg.add_triple("Max", "child_of", "Alice", Some("2015-04-01"), None).await?;
12//! let facts = kg.query_entity("Max", None, "outgoing").await?;
13//! # Ok(())
14//! # }
15//! ```
16
17use chrono::Local;
18
19use crate::{
20    db::{AimemDb, DbError},
21    types::{Entity, Triple},
22};
23
24#[derive(Debug, Clone)]
25pub struct KnowledgeGraph {
26    db: AimemDb,
27}
28
29impl KnowledgeGraph {
30    pub fn new(db: AimemDb) -> Self {
31        Self { db }
32    }
33
34    // ── Entities ─────────────────────────────────────────────────────────────
35
36    /// Add or update an entity node.
37    pub async fn add_entity(
38        &self,
39        name: &str,
40        entity_type: &str,
41        properties: Option<serde_json::Value>,
42    ) -> Result<String, DbError> {
43        let id = entity_id(name);
44        let props = properties
45            .map(|p| serde_json::to_string(&p).unwrap_or_else(|_| "{}".to_string()))
46            .unwrap_or_else(|| "{}".to_string());
47        let entity = Entity {
48            id: id.clone(),
49            name: name.to_string(),
50            entity_type: entity_type.to_string(),
51            properties: props,
52            created_at: chrono::Utc::now().to_rfc3339(),
53        };
54        self.db.upsert_entity(&entity).await?;
55        Ok(id)
56    }
57
58    // ── Triples ───────────────────────────────────────────────────────────────
59
60    /// Add a triple (subject → predicate → object).
61    ///
62    /// Auto-creates entity nodes if they don't exist.
63    /// Returns the triple ID, or the existing ID if an identical active triple exists.
64    pub async fn add_triple(
65        &self,
66        subject: &str,
67        predicate: &str,
68        object: &str,
69        valid_from: Option<&str>,
70        valid_to: Option<&str>,
71    ) -> Result<String, DbError> {
72        // Ensure entities exist
73        self.add_entity(subject, "unknown", None).await?;
74        self.add_entity(object, "unknown", None).await?;
75
76        let sub_id = entity_id(subject);
77        let obj_id = entity_id(object);
78        let pred = normalize_predicate(predicate);
79
80        // Dedup: if an identical active triple exists, return its ID
81        let conn = self.db.conn()?;
82        let mut existing = conn
83            .query(
84                "SELECT id FROM triples \
85                 WHERE subject = ?1 AND predicate = ?2 AND object = ?3 AND valid_to IS NULL",
86                [sub_id.as_str(), pred.as_str(), obj_id.as_str()],
87            )
88            .await?;
89        if let Some(row) = existing.next().await? {
90            if let Ok(turso::Value::Text(id)) = row.get_value(0) {
91                return Ok(id);
92            }
93        }
94
95        let now = chrono::Utc::now().to_rfc3339();
96        let triple_id = {
97            let input = format!("{sub_id}{pred}{obj_id}{now}");
98            let digest = md5::compute(input.as_bytes());
99            format!("t_{sub_id}_{pred}_{obj_id}_{digest:x}")
100        };
101
102        let triple = Triple {
103            id: triple_id.clone(),
104            subject: sub_id,
105            predicate: pred,
106            object: obj_id,
107            valid_from: valid_from.map(str::to_string),
108            valid_to: valid_to.map(str::to_string),
109            confidence: 1.0,
110            source_closet: None,
111            source_file: None,
112            extracted_at: now,
113            current: true,
114        };
115        self.db.insert_triple(&triple).await?;
116        Ok(triple_id)
117    }
118
119    /// Mark a triple as no longer valid.
120    pub async fn invalidate(
121        &self,
122        subject: &str,
123        predicate: &str,
124        object: &str,
125        ended: Option<&str>,
126    ) -> Result<u64, DbError> {
127        let sub_id = entity_id(subject);
128        let obj_id = entity_id(object);
129        let pred = normalize_predicate(predicate);
130        let ended = ended
131            .map(str::to_string)
132            .unwrap_or_else(|| Local::now().date_naive().to_string());
133        self.db
134            .invalidate_triple(&sub_id, &pred, &obj_id, &ended)
135            .await
136    }
137
138    // ── Queries ───────────────────────────────────────────────────────────────
139
140    /// Get all relationships for an entity.
141    ///
142    /// * `direction` — `"outgoing"` (entity→?), `"incoming"` (?→entity), `"both"`
143    /// * `as_of`     — only facts valid at this date (YYYY-MM-DD)
144    pub async fn query_entity(
145        &self,
146        name: &str,
147        as_of: Option<&str>,
148        direction: &str,
149    ) -> Result<Vec<Triple>, DbError> {
150        let eid = entity_id(name);
151        let conn = self.db.conn()?;
152        let mut results = Vec::new();
153
154        if direction == "outgoing" || direction == "both" {
155            let sql = build_entity_query("subject", as_of);
156            let mut rows = conn.query(&sql, [eid.as_str()]).await?;
157            while let Some(row) = rows.next().await? {
158                results.push(row_to_triple(&row)?);
159            }
160        }
161
162        if direction == "incoming" || direction == "both" {
163            let sql = build_entity_query("object", as_of);
164            let mut rows = conn.query(&sql, [eid.as_str()]).await?;
165            while let Some(row) = rows.next().await? {
166                results.push(row_to_triple(&row)?);
167            }
168        }
169
170        Ok(results)
171    }
172
173    /// Chronological timeline of facts (optionally filtered by entity).
174    pub async fn timeline(&self, entity: Option<&str>) -> Result<Vec<Triple>, DbError> {
175        let conn = self.db.conn()?;
176        let mut results = Vec::new();
177
178        if let Some(name) = entity {
179            let eid = entity_id(name);
180            let mut rows = conn
181                .query(
182                    "SELECT id, subject, predicate, object, valid_from, valid_to, \
183                     confidence, source_closet, source_file, extracted_at \
184                     FROM triples \
185                     WHERE subject = ?1 OR object = ?1 \
186                     ORDER BY valid_from ASC LIMIT 100",
187                    [eid.as_str()],
188                )
189                .await?;
190            while let Some(row) = rows.next().await? {
191                results.push(row_to_triple(&row)?);
192            }
193        } else {
194            let mut rows = conn
195                .query(
196                    "SELECT id, subject, predicate, object, valid_from, valid_to, \
197                     confidence, source_closet, source_file, extracted_at \
198                     FROM triples \
199                     ORDER BY valid_from ASC LIMIT 100",
200                    (),
201                )
202                .await?;
203            while let Some(row) = rows.next().await? {
204                results.push(row_to_triple(&row)?);
205            }
206        }
207
208        Ok(results)
209    }
210
211    /// Knowledge graph statistics.
212    pub async fn stats(&self) -> Result<serde_json::Value, DbError> {
213        let conn = self.db.conn()?;
214
215        let entities = query_count(&conn, "SELECT COUNT(*) FROM entities").await?;
216        let triples = query_count(&conn, "SELECT COUNT(*) FROM triples").await?;
217        let current =
218            query_count(&conn, "SELECT COUNT(*) FROM triples WHERE valid_to IS NULL").await?;
219
220        let mut pred_rows = conn
221            .query(
222                "SELECT DISTINCT predicate FROM triples ORDER BY predicate",
223                (),
224            )
225            .await?;
226        let mut predicates = Vec::new();
227        while let Some(row) = pred_rows.next().await? {
228            if let Ok(turso::Value::Text(p)) = row.get_value(0) {
229                predicates.push(p);
230            }
231        }
232
233        Ok(serde_json::json!({
234            "entities": entities,
235            "triples": triples,
236            "current_facts": current,
237            "expired_facts": triples - current,
238            "relationship_types": predicates,
239        }))
240    }
241}
242
243// ── Helpers ───────────────────────────────────────────────────────────────────
244
245fn entity_id(name: &str) -> String {
246    name.to_lowercase().replace(' ', "_").replace('\'', "")
247}
248
249fn normalize_predicate(pred: &str) -> String {
250    pred.to_lowercase().replace(' ', "_")
251}
252
253fn build_entity_query(col: &str, as_of: Option<&str>) -> String {
254    let mut q = format!(
255        "SELECT id, subject, predicate, object, valid_from, valid_to, \
256         confidence, source_closet, source_file, extracted_at \
257         FROM triples WHERE {col} = ?1"
258    );
259    if let Some(date) = as_of {
260        q.push_str(&format!(
261            " AND (valid_from IS NULL OR valid_from <= '{date}') \
262             AND (valid_to IS NULL OR valid_to >= '{date}')"
263        ));
264    }
265    q
266}
267
268fn row_to_triple(row: &turso::Row) -> Result<Triple, DbError> {
269    let valid_to_str = val_opt_str(row, 5);
270    Ok(Triple {
271        id: val_str(row, 0),
272        subject: val_str(row, 1),
273        predicate: val_str(row, 2),
274        object: val_str(row, 3),
275        valid_from: val_opt_str(row, 4),
276        valid_to: valid_to_str.clone(),
277        confidence: match row.get_value(6)? {
278            turso::Value::Real(f) => f,
279            turso::Value::Integer(i) => i as f64,
280            _ => 1.0,
281        },
282        source_closet: val_opt_str(row, 7),
283        source_file: val_opt_str(row, 8),
284        extracted_at: val_str(row, 9),
285        current: valid_to_str.is_none(),
286    })
287}
288
289fn val_str(row: &turso::Row, idx: usize) -> String {
290    match row.get_value(idx) {
291        Ok(turso::Value::Text(s)) => s,
292        Ok(turso::Value::Null) | Err(_) => String::new(),
293        Ok(v) => format!("{v:?}"),
294    }
295}
296
297fn val_opt_str(row: &turso::Row, idx: usize) -> Option<String> {
298    match row.get_value(idx) {
299        Ok(turso::Value::Text(s)) if !s.is_empty() => Some(s),
300        _ => None,
301    }
302}
303
304async fn query_count(conn: &turso::Connection, sql: &str) -> Result<i64, DbError> {
305    let mut rows = conn.query(sql, ()).await?;
306    let row = rows.next().await?;
307    Ok(row
308        .and_then(|r| r.get_value(0).ok())
309        .and_then(|v| v.as_integer().copied())
310        .unwrap_or(0))
311}