Skip to main content

lean_ctx/core/
knowledge_relations.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
6pub struct KnowledgeNodeRef {
7    pub category: String,
8    pub key: String,
9}
10
11impl KnowledgeNodeRef {
12    pub fn new(category: &str, key: &str) -> Self {
13        Self {
14            category: category.trim().to_string(),
15            key: key.trim().to_string(),
16        }
17    }
18
19    pub fn id(&self) -> String {
20        format!("{}/{}", self.category, self.key)
21    }
22}
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
25#[serde(rename_all = "snake_case")]
26pub enum KnowledgeEdgeKind {
27    DependsOn,
28    RelatedTo,
29    Supports,
30    Contradicts,
31    Supersedes,
32}
33
34impl KnowledgeEdgeKind {
35    pub fn parse(input: &str) -> Option<Self> {
36        match input.trim().to_lowercase().as_str() {
37            "depends_on" | "depends" => Some(Self::DependsOn),
38            "related_to" | "related" => Some(Self::RelatedTo),
39            "supports" | "support" => Some(Self::Supports),
40            "contradicts" | "contradict" => Some(Self::Contradicts),
41            "supersedes" | "supersede" => Some(Self::Supersedes),
42            _ => None,
43        }
44    }
45
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            KnowledgeEdgeKind::DependsOn => "depends_on",
49            KnowledgeEdgeKind::RelatedTo => "related_to",
50            KnowledgeEdgeKind::Supports => "supports",
51            KnowledgeEdgeKind::Contradicts => "contradicts",
52            KnowledgeEdgeKind::Supersedes => "supersedes",
53        }
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct KnowledgeEdge {
59    pub from: KnowledgeNodeRef,
60    pub to: KnowledgeNodeRef,
61    pub kind: KnowledgeEdgeKind,
62    pub created_at: DateTime<Utc>,
63    #[serde(default)]
64    pub last_seen: Option<DateTime<Utc>>,
65    #[serde(default)]
66    pub count: u32,
67    pub source_session: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(default)]
72pub struct KnowledgeRelationGraph {
73    pub project_hash: String,
74    pub edges: Vec<KnowledgeEdge>,
75    pub updated_at: DateTime<Utc>,
76}
77
78impl Default for KnowledgeRelationGraph {
79    fn default() -> Self {
80        Self {
81            project_hash: String::new(),
82            edges: Vec::new(),
83            updated_at: Utc::now(),
84        }
85    }
86}
87
88impl KnowledgeRelationGraph {
89    pub fn new(project_hash: &str) -> Self {
90        Self {
91            project_hash: project_hash.to_string(),
92            edges: Vec::new(),
93            updated_at: Utc::now(),
94        }
95    }
96
97    pub fn path(project_hash: &str) -> Result<PathBuf, String> {
98        let dir = crate::core::data_dir::lean_ctx_data_dir()?
99            .join("knowledge")
100            .join(project_hash);
101        Ok(dir.join("relations.json"))
102    }
103
104    pub fn load(project_hash: &str) -> Option<Self> {
105        let path = Self::path(project_hash).ok()?;
106        let content = std::fs::read_to_string(&path).ok()?;
107        let mut g = serde_json::from_str::<Self>(&content).ok()?;
108        if g.project_hash.trim().is_empty() {
109            g.project_hash = project_hash.to_string();
110        }
111        Some(g)
112    }
113
114    pub fn load_or_create(project_hash: &str) -> Self {
115        Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
116    }
117
118    pub fn save(&mut self) -> Result<(), String> {
119        let path = Self::path(&self.project_hash)?;
120        if let Some(dir) = path.parent() {
121            std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
122        }
123
124        self.updated_at = Utc::now();
125        self.edges.sort_by(|a, b| {
126            a.from
127                .category
128                .cmp(&b.from.category)
129                .then_with(|| a.from.key.cmp(&b.from.key))
130                .then_with(|| a.kind.as_str().cmp(b.kind.as_str()))
131                .then_with(|| a.to.category.cmp(&b.to.category))
132                .then_with(|| a.to.key.cmp(&b.to.key))
133                .then_with(|| b.count.cmp(&a.count))
134                .then_with(|| b.last_seen.cmp(&a.last_seen))
135                .then_with(|| b.created_at.cmp(&a.created_at))
136        });
137
138        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
139        std::fs::write(&path, json).map_err(|e| e.to_string())
140    }
141
142    pub fn upsert_edge(
143        &mut self,
144        from: KnowledgeNodeRef,
145        to: KnowledgeNodeRef,
146        kind: KnowledgeEdgeKind,
147        session_id: &str,
148    ) -> bool {
149        let now = Utc::now();
150        if let Some(e) = self
151            .edges
152            .iter_mut()
153            .find(|e| e.from == from && e.to == to && e.kind == kind)
154        {
155            e.count = e.count.saturating_add(1).max(1);
156            e.last_seen = Some(now);
157            e.source_session = session_id.to_string();
158            self.updated_at = now;
159            return false;
160        }
161
162        self.edges.push(KnowledgeEdge {
163            from,
164            to,
165            kind,
166            created_at: now,
167            last_seen: Some(now),
168            count: 1,
169            source_session: session_id.to_string(),
170        });
171        self.updated_at = now;
172        true
173    }
174
175    pub fn remove_edge(
176        &mut self,
177        from: &KnowledgeNodeRef,
178        to: &KnowledgeNodeRef,
179        kind: Option<KnowledgeEdgeKind>,
180    ) -> usize {
181        let before = self.edges.len();
182        self.edges.retain(|e| {
183            if &e.from != from || &e.to != to {
184                return true;
185            }
186            if let Some(k) = kind {
187                e.kind != k
188            } else {
189                false
190            }
191        });
192        before.saturating_sub(self.edges.len())
193    }
194
195    pub fn enforce_cap(&mut self, max_edges: usize) -> bool {
196        if max_edges == 0 || self.edges.len() <= max_edges {
197            return false;
198        }
199
200        self.edges.sort_by(|a, b| {
201            b.count
202                .cmp(&a.count)
203                .then_with(|| b.last_seen.cmp(&a.last_seen))
204                .then_with(|| b.created_at.cmp(&a.created_at))
205                .then_with(|| a.from.category.cmp(&b.from.category))
206                .then_with(|| a.from.key.cmp(&b.from.key))
207                .then_with(|| a.kind.as_str().cmp(b.kind.as_str()))
208                .then_with(|| a.to.category.cmp(&b.to.category))
209                .then_with(|| a.to.key.cmp(&b.to.key))
210        });
211
212        self.edges.truncate(max_edges);
213        true
214    }
215}
216
217pub fn parse_node_ref(input: &str) -> Option<KnowledgeNodeRef> {
218    let s = input.trim();
219    if s.is_empty() {
220        return None;
221    }
222
223    if let Some((cat, key)) = s.split_once('/') {
224        let cat = cat.trim();
225        let key = key.trim();
226        if !cat.is_empty() && !key.is_empty() {
227            return Some(KnowledgeNodeRef::new(cat, key));
228        }
229    }
230    if let Some((cat, key)) = s.split_once(':') {
231        let cat = cat.trim();
232        let key = key.trim();
233        if !cat.is_empty() && !key.is_empty() {
234            return Some(KnowledgeNodeRef::new(cat, key));
235        }
236    }
237
238    None
239}
240
241pub fn format_mermaid(edges: &[KnowledgeEdge]) -> String {
242    if edges.is_empty() {
243        return "graph TD\n  %% no relations".to_string();
244    }
245
246    fn id_for(n: &KnowledgeNodeRef) -> String {
247        let mut out = String::from("K_");
248        for ch in n.id().chars() {
249            if ch.is_ascii_alphanumeric() {
250                out.push(ch);
251            } else {
252                out.push('_');
253            }
254        }
255        out
256    }
257
258    let mut lines = Vec::new();
259    lines.push("graph TD".to_string());
260    for e in edges {
261        let from = id_for(&e.from);
262        let to = id_for(&e.to);
263        let from_label = e.from.id();
264        let to_label = e.to.id();
265        lines.push(format!(
266            "  {from}[\"{from_label}\"] -->|{}| {to}[\"{to_label}\"]",
267            e.kind.as_str()
268        ));
269    }
270    lines.join("\n")
271}