1use anyhow::Result;
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6
7use super::surreal::SurrealMemory;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct GraphNode {
11 pub id: String,
12 pub kind: String,
13 pub text: String,
14 pub confidence: f32,
15 pub status: String,
16 pub created_at: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct GraphEdge {
21 pub from_id: String,
22 pub to_id: String,
23 pub rel: String,
24 pub created_at: String,
25}
26
27fn slug_id(prefix: &str, text: &str) -> String {
28 let mut s: String = text
29 .chars()
30 .map(|c| {
31 if c.is_ascii_alphanumeric() {
32 c.to_ascii_lowercase()
33 } else {
34 '-'
35 }
36 })
37 .collect();
38 while s.contains("--") {
39 s = s.replace("--", "-");
40 }
41 s = s.trim_matches('-').chars().take(48).collect();
42 format!("{prefix}-{s}")
43}
44
45impl SurrealMemory {
46 pub async fn graph_upsert_node(
47 &self,
48 kind: &str,
49 text: &str,
50 confidence: f32,
51 status: &str,
52 ) -> Result<String> {
53 let id = slug_id(kind, text);
54 let row = GraphNode {
55 id: id.clone(),
56 kind: kind.to_string(),
57 text: text.to_string(),
58 confidence,
59 status: status.to_string(),
60 created_at: Utc::now().to_rfc3339(),
61 };
62 let _: Option<GraphNode> = self
63 .db()
64 .await?
65 .upsert(("memory_nodes", id.as_str()))
66 .content(row)
67 .await?;
68 Ok(id)
69 }
70
71 pub async fn graph_link(&self, from_id: &str, to_id: &str, rel: &str) -> Result<()> {
72 let edge_id = format!("{from_id}::{rel}::{to_id}");
73 let row = GraphEdge {
74 from_id: from_id.to_string(),
75 to_id: to_id.to_string(),
76 rel: rel.to_string(),
77 created_at: Utc::now().to_rfc3339(),
78 };
79 let _: Option<GraphEdge> = self
80 .db()
81 .await?
82 .upsert(("memory_edges", edge_id.as_str()))
83 .content(row)
84 .await?;
85 Ok(())
86 }
87
88 pub async fn graph_neighbors(&self, node_id: &str, limit: usize) -> Result<Vec<GraphNode>> {
89 let mut res = self
90 .db()
91 .await?
92 .query("SELECT * FROM memory_edges WHERE from_id = $id OR to_id = $id LIMIT 50")
93 .bind(("id", node_id.to_string()))
94 .await?;
95 let edges: Vec<GraphEdge> = res.take(0)?;
96 let mut nodes = Vec::new();
97 for e in edges {
98 let other = if e.from_id == node_id {
99 e.to_id.as_str()
100 } else {
101 e.from_id.as_str()
102 };
103 let n: Option<GraphNode> = self.db().await?.select(("memory_nodes", other)).await?;
104 if let Some(n) = n {
105 nodes.push(n);
106 }
107 if nodes.len() >= limit {
108 break;
109 }
110 }
111 Ok(nodes)
112 }
113
114 pub async fn graph_match_text(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
115 let q = query.to_lowercase();
116 let words: Vec<&str> = q.split_whitespace().filter(|w| w.len() > 2).collect();
117 if words.is_empty() {
118 return Ok(Vec::new());
119 }
120 let mut res = self
121 .db()
122 .await?
123 .query("SELECT * FROM memory_nodes LIMIT 200")
124 .await?;
125 let all: Vec<GraphNode> = res.take(0)?;
126 let mut scored: Vec<(f32, GraphNode)> = all
127 .into_iter()
128 .filter_map(|n| {
129 let t = n.text.to_lowercase();
130 let score = words.iter().filter(|w| t.contains(**w)).count() as f32;
131 if score > 0.0 {
132 Some((score, n))
133 } else {
134 None
135 }
136 })
137 .collect();
138 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
139 Ok(scored.into_iter().take(limit).map(|(_, n)| n).collect())
140 }
141
142 pub async fn graph_dream_from_loops(&self, open_loops: &[String]) -> Result<usize> {
144 let mut n = 0usize;
145 for line in open_loops.iter().take(5) {
146 let parent = self.graph_upsert_node("loop", line, 0.9, "active").await?;
147 let dream_text = format!("What if we explored: {line}?");
148 let child = self
149 .graph_upsert_node("dream", &dream_text, 0.35, "dream")
150 .await?;
151 self.graph_link(&parent, &child, "hypothesized").await?;
152 n += 1;
153 }
154 Ok(n)
155 }
156
157 pub fn format_graph_context(&self, nodes: &[GraphNode]) -> Option<String> {
158 if nodes.is_empty() {
159 return None;
160 }
161 let mut out = String::from("<idea_graph>\n");
162 for n in nodes {
163 out.push_str(&format!(
164 " - [{}:{}] {} (conf {:.2})\n",
165 n.kind, n.status, n.text, n.confidence
166 ));
167 }
168 out.push_str("</idea_graph>");
169 Some(out)
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn slug_stable() {
179 assert!(slug_id("idea", "Hello World").starts_with("idea-"));
180 }
181}