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 .upsert(("memory_nodes", id.as_str()))
65 .content(row)
66 .await?;
67 Ok(id)
68 }
69
70 pub async fn graph_link(&self, from_id: &str, to_id: &str, rel: &str) -> Result<()> {
71 let edge_id = format!("{from_id}::{rel}::{to_id}");
72 let row = GraphEdge {
73 from_id: from_id.to_string(),
74 to_id: to_id.to_string(),
75 rel: rel.to_string(),
76 created_at: Utc::now().to_rfc3339(),
77 };
78 let _: Option<GraphEdge> = self
79 .db()
80 .upsert(("memory_edges", edge_id.as_str()))
81 .content(row)
82 .await?;
83 Ok(())
84 }
85
86 pub async fn graph_neighbors(&self, node_id: &str, limit: usize) -> Result<Vec<GraphNode>> {
87 let mut res = self
88 .db()
89 .query("SELECT * FROM memory_edges WHERE from_id = $id OR to_id = $id LIMIT 50")
90 .bind(("id", node_id.to_string()))
91 .await?;
92 let edges: Vec<GraphEdge> = res.take(0)?;
93 let mut nodes = Vec::new();
94 for e in edges {
95 let other = if e.from_id == node_id {
96 e.to_id.as_str()
97 } else {
98 e.from_id.as_str()
99 };
100 let n: Option<GraphNode> = self.db().select(("memory_nodes", other)).await?;
101 if let Some(n) = n {
102 nodes.push(n);
103 }
104 if nodes.len() >= limit {
105 break;
106 }
107 }
108 Ok(nodes)
109 }
110
111 pub async fn graph_match_text(&self, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
112 let q = query.to_lowercase();
113 let words: Vec<&str> = q.split_whitespace().filter(|w| w.len() > 2).collect();
114 if words.is_empty() {
115 return Ok(Vec::new());
116 }
117 let mut res = self
118 .db()
119 .query("SELECT * FROM memory_nodes LIMIT 200")
120 .await?;
121 let all: Vec<GraphNode> = res.take(0)?;
122 let mut scored: Vec<(f32, GraphNode)> = all
123 .into_iter()
124 .filter_map(|n| {
125 let t = n.text.to_lowercase();
126 let score = words.iter().filter(|w| t.contains(**w)).count() as f32;
127 if score > 0.0 {
128 Some((score, n))
129 } else {
130 None
131 }
132 })
133 .collect();
134 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
135 Ok(scored.into_iter().take(limit).map(|(_, n)| n).collect())
136 }
137
138 pub async fn graph_dream_from_loops(&self, open_loops: &[String]) -> Result<usize> {
140 let mut n = 0usize;
141 for line in open_loops.iter().take(5) {
142 let parent = self.graph_upsert_node("loop", line, 0.9, "active").await?;
143 let dream_text = format!("What if we explored: {line}?");
144 let child = self
145 .graph_upsert_node("dream", &dream_text, 0.35, "dream")
146 .await?;
147 self.graph_link(&parent, &child, "hypothesized").await?;
148 n += 1;
149 }
150 Ok(n)
151 }
152
153 pub fn format_graph_context(&self, nodes: &[GraphNode]) -> Option<String> {
154 if nodes.is_empty() {
155 return None;
156 }
157 let mut out = String::from("<idea_graph>\n");
158 for n in nodes {
159 out.push_str(&format!(
160 " - [{}:{}] {} (conf {:.2})\n",
161 n.kind, n.status, n.text, n.confidence
162 ));
163 }
164 out.push_str("</idea_graph>");
165 Some(out)
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn slug_stable() {
175 assert!(slug_id("idea", "Hello World").starts_with("idea-"));
176 }
177}