Skip to main content

agent_office/services/kb/
domain.rs

1use crate::domain::{Node, NodeId, Properties, PropertyValue, Timestamp};
2use chrono::Utc;
3use serde::{Deserialize, Serialize};
4
5pub type NoteId = NodeId;
6pub type TagId = NodeId;
7pub type AgentId = String;
8
9/// Luhmann-style hierarchical ID for Zettelkasten notes
10/// Format alternates numbers and letters: 1, 1a, 1a1, 1a1a, 1a2, 1b, 2, etc.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct LuhmannId {
13    pub parts: Vec<LuhmannPart>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub enum LuhmannPart {
18    Number(u32),
19    Letter(char),
20}
21
22impl LuhmannId {
23    /// Parse a Luhmann ID from string like "1a2b"
24    pub fn parse(s: &str) -> Option<Self> {
25        let mut parts = Vec::new();
26        let mut chars = s.chars().peekable();
27
28        while let Some(&c) = chars.peek() {
29            if c.is_ascii_digit() {
30                // Parse number
31                let mut num_str = String::new();
32                while let Some(&ch) = chars.peek() {
33                    if ch.is_ascii_digit() {
34                        num_str.push(ch);
35                        chars.next();
36                    } else {
37                        break;
38                    }
39                }
40                if let Ok(n) = num_str.parse::<u32>() {
41                    parts.push(LuhmannPart::Number(n));
42                }
43            } else if c.is_ascii_alphabetic() {
44                // Parse letter (single char)
45                parts.push(LuhmannPart::Letter(c.to_ascii_lowercase()));
46                chars.next();
47            } else {
48                chars.next(); // Skip invalid char
49            }
50        }
51
52        if parts.is_empty() {
53            None
54        } else {
55            Some(Self { parts })
56        }
57    }
58
59    /// Get the parent ID (one level up)
60    pub fn parent(&self) -> Option<Self> {
61        if self.parts.len() <= 1 {
62            None
63        } else {
64            Some(Self {
65                parts: self.parts[..self.parts.len() - 1].to_vec(),
66            })
67        }
68    }
69
70    /// Get the next sibling at the same level
71    pub fn next_sibling(&self) -> Option<Self> {
72        if let Some(last) = self.parts.last() {
73            let mut new_parts = self.parts.clone();
74            match last {
75                LuhmannPart::Number(n) => {
76                    new_parts.pop();
77                    new_parts.push(LuhmannPart::Number(n + 1));
78                }
79                LuhmannPart::Letter(c) => {
80                    if let Some(next_char) = (*c as u8 + 1).try_into().ok() {
81                        if next_char <= 'z' {
82                            new_parts.pop();
83                            new_parts.push(LuhmannPart::Letter(next_char));
84                        } else {
85                            return None; // Can't go past 'z'
86                        }
87                    }
88                }
89            }
90            Some(Self { parts: new_parts })
91        } else {
92            None
93        }
94    }
95
96    /// Get the first child ID (branch off from this note)
97    pub fn first_child(&self) -> Self {
98        let mut new_parts = self.parts.clone();
99        // Alternate: if last was number, add letter; if letter, add number
100        match self.parts.last() {
101            Some(LuhmannPart::Number(_)) => {
102                new_parts.push(LuhmannPart::Letter('a'));
103            }
104            Some(LuhmannPart::Letter(_)) | None => {
105                new_parts.push(LuhmannPart::Number(1));
106            }
107        }
108        Self { parts: new_parts }
109    }
110
111    /// Insert between this ID and the next (e.g., between 1 and 2 → 1a)
112    pub fn insert_between(&self, next: &Self) -> Option<Self> {
113        // Can only insert if we're siblings (same parent)
114        if self.parent() != next.parent() {
115            return None;
116        }
117
118        // For simplicity, just add 'a' suffix to current
119        let mut new_parts = self.parts.clone();
120        match self.parts.last() {
121            Some(LuhmannPart::Number(_)) => {
122                new_parts.push(LuhmannPart::Letter('a'));
123            }
124            Some(LuhmannPart::Letter(_)) => {
125                new_parts.push(LuhmannPart::Number(1));
126            }
127            None => {}
128        }
129        Some(Self { parts: new_parts })
130    }
131
132    /// Get the level/depth of this ID
133    pub fn level(&self) -> usize {
134        self.parts.len()
135    }
136
137    /// Check if this ID is a descendant of another
138    pub fn is_descendant_of(&self, other: &Self) -> bool {
139        if other.parts.len() >= self.parts.len() {
140            return false;
141        }
142        self.parts[..other.parts.len()] == other.parts[..]
143    }
144}
145
146impl std::fmt::Display for LuhmannId {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        for part in &self.parts {
149            match part {
150                LuhmannPart::Number(n) => write!(f, "{}", n)?,
151                LuhmannPart::Letter(c) => write!(f, "{}", c)?,
152            }
153        }
154        Ok(())
155    }
156}
157
158impl std::str::FromStr for LuhmannId {
159    type Err = String;
160
161    fn from_str(s: &str) -> Result<Self, Self::Err> {
162        Self::parse(s).ok_or_else(|| format!("Invalid Luhmann ID: {}", s))
163    }
164}
165
166/// Simple link type - just "references" with optional context
167/// The Luhmann ID provides implicit structure (hierarchy, sequence)
168#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
169#[serde(rename_all = "snake_case")]
170pub enum LinkType {
171    /// General reference/link between notes
172    References,
173}
174
175impl LinkType {
176    pub fn as_str(&self) -> &'static str {
177        "references"
178    }
179
180    pub fn from_str(s: &str) -> Option<Self> {
181        match s {
182            "references" => Some(LinkType::References),
183            _ => None,
184        }
185    }
186}
187
188/// A Zettelkasten-style atomic note
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct Note {
191    pub id: NoteId,
192    pub luhmann_id: Option<LuhmannId>, // Optional hierarchical address
193    pub title: String,
194    pub content: String,
195    pub created_by: AgentId,
196    pub tags: Vec<String>,
197    pub created_at: Timestamp,
198    pub updated_at: Timestamp,
199}
200
201impl Note {
202    pub fn new(created_by: AgentId, title: impl Into<String>, content: impl Into<String>) -> Self {
203        let now = Utc::now();
204        Self {
205            id: NoteId::new_v4(),
206            luhmann_id: None,
207            title: title.into(),
208            content: content.into(),
209            created_by,
210            tags: Vec::new(),
211            created_at: now,
212            updated_at: now,
213        }
214    }
215
216    pub fn with_luhmann_id(mut self, luhmann_id: LuhmannId) -> Self {
217        self.luhmann_id = Some(luhmann_id);
218        self
219    }
220
221    pub fn to_node(&self) -> Node {
222        let mut props = Properties::new();
223        props.insert(
224            "title".to_string(),
225            PropertyValue::String(self.title.clone()),
226        );
227        props.insert(
228            "content".to_string(),
229            PropertyValue::String(self.content.clone()),
230        );
231        props.insert(
232            "created_by".to_string(),
233            PropertyValue::String(self.created_by.to_string()),
234        );
235        if let Some(ref lid) = self.luhmann_id {
236            props.insert(
237                "luhmann_id".to_string(),
238                PropertyValue::String(lid.to_string()),
239            );
240        }
241        props.insert(
242            "tags".to_string(),
243            PropertyValue::List(
244                self.tags
245                    .iter()
246                    .map(|t| PropertyValue::String(t.clone()))
247                    .collect(),
248            ),
249        );
250
251        let mut node = Node::new("note", props);
252        node.id = self.id;
253        node.created_at = self.created_at;
254        node.updated_at = self.updated_at;
255        node
256    }
257
258    pub fn from_node(node: &Node) -> Option<Self> {
259        if node.node_type != "note" {
260            return None;
261        }
262
263        let title = node.get_property("title")?.as_str()?.to_string();
264        let content = node.get_property("content")?.as_str()?.to_string();
265
266        let created_by = node
267            .get_property("created_by")
268            .and_then(|v| v.as_str())
269            .map(|s| s.to_string())?;
270
271        let luhmann_id = node
272            .get_property("luhmann_id")
273            .and_then(|v| v.as_str())
274            .and_then(|s| LuhmannId::parse(s));
275
276        let tags = node
277            .get_property("tags")
278            .and_then(|v| match v {
279                PropertyValue::List(list) => Some(
280                    list.iter()
281                        .filter_map(|item| item.as_str().map(String::from))
282                        .collect(),
283                ),
284                _ => None,
285            })
286            .unwrap_or_default();
287
288        Some(Self {
289            id: node.id,
290            luhmann_id,
291            title,
292            content,
293            created_by,
294            tags,
295            created_at: node.created_at,
296            updated_at: node.updated_at,
297        })
298    }
299
300    pub fn add_tag(&mut self, tag: impl Into<String>) {
301        let tag = tag.into();
302        if !self.tags.contains(&tag) {
303            self.tags.push(tag);
304        }
305        self.updated_at = Utc::now();
306    }
307
308    pub fn remove_tag(&mut self, tag: &str) {
309        self.tags.retain(|t| t != tag);
310        self.updated_at = Utc::now();
311    }
312}
313
314/// A note link (relationship between two notes)
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
316pub struct NoteLink {
317    pub from_note_id: NoteId,
318    pub to_note_id: NoteId,
319    pub link_type: LinkType,
320    pub context: Option<String>,
321}
322
323impl NoteLink {
324    pub fn new(
325        from_note_id: NoteId,
326        to_note_id: NoteId,
327        link_type: LinkType,
328        context: Option<String>,
329    ) -> Self {
330        Self {
331            from_note_id,
332            to_note_id,
333            link_type,
334            context,
335        }
336    }
337}
338
339/// Represents a knowledge base namespace for an agent
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct KnowledgeBase {
342    pub agent_id: AgentId,
343    pub name: String,
344    pub description: Option<String>,
345    pub created_at: Timestamp,
346    pub next_main_id: u32, // For auto-generating Luhmann IDs (1, 2, 3...)
347}
348
349impl KnowledgeBase {
350    pub fn new(agent_id: AgentId, name: impl Into<String>) -> Self {
351        Self {
352            agent_id,
353            name: name.into(),
354            description: None,
355            created_at: Utc::now(),
356            next_main_id: 1,
357        }
358    }
359
360    pub fn to_node(&self) -> Node {
361        let mut props = Properties::new();
362        props.insert("name".to_string(), PropertyValue::String(self.name.clone()));
363        if let Some(desc) = &self.description {
364            props.insert(
365                "description".to_string(),
366                PropertyValue::String(desc.clone()),
367            );
368        }
369        props.insert(
370            "next_main_id".to_string(),
371            PropertyValue::Integer(self.next_main_id as i64),
372        );
373
374        let mut node = Node::new("knowledge_base", props);
375        // Convert string agent_id to deterministic UUID
376        node.id = crate::domain::string_to_node_id(&self.agent_id);
377        node.created_at = self.created_at;
378        node
379    }
380
381    pub fn from_node(node: &Node) -> Option<Self> {
382        if node.node_type != "knowledge_base" {
383            return None;
384        }
385
386        let name = node.get_property("name")?.as_str()?.to_string();
387        let description = node
388            .get_property("description")
389            .and_then(|v| v.as_str())
390            .map(String::from);
391        let next_main_id = node
392            .get_property("next_main_id")
393            .and_then(|v| match v {
394                PropertyValue::Integer(n) => Some(*n as u32),
395                _ => Some(1),
396            })
397            .unwrap_or(1);
398
399        // Get agent_id from properties, fallback to converting node.id
400        let agent_id = node
401            .get_property("agent_id")
402            .and_then(|v| v.as_str())
403            .map(String::from)
404            .unwrap_or_else(|| node.id.to_string());
405
406        Some(Self {
407            agent_id,
408            name,
409            description,
410            created_at: node.created_at,
411            next_main_id,
412        })
413    }
414
415    /// Generate the next main topic ID (1, 2, 3...)
416    pub fn next_main_topic_id(&mut self) -> LuhmannId {
417        let id = LuhmannId {
418            parts: vec![LuhmannPart::Number(self.next_main_id)],
419        };
420        self.next_main_id += 1;
421        id
422    }
423}
424
425/// Graph path result for traversing the knowledge graph
426#[derive(Debug, Clone)]
427pub struct GraphPath {
428    pub start_note_id: NoteId,
429    pub end_note_id: NoteId,
430    pub path: Vec<(NoteId, LinkType)>,
431    pub distance: usize,
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn test_luhmann_id_parsing() {
440        let id = LuhmannId::parse("1a2b").unwrap();
441        assert_eq!(id.parts.len(), 4);
442        assert!(matches!(id.parts[0], LuhmannPart::Number(1)));
443        assert!(matches!(id.parts[1], LuhmannPart::Letter('a')));
444        assert!(matches!(id.parts[2], LuhmannPart::Number(2)));
445        assert!(matches!(id.parts[3], LuhmannPart::Letter('b')));
446    }
447
448    #[test]
449    fn test_luhmann_id_display() {
450        let id = LuhmannId::parse("1a2").unwrap();
451        assert_eq!(id.to_string(), "1a2");
452    }
453
454    #[test]
455    fn test_luhmann_parent() {
456        let id = LuhmannId::parse("1a2").unwrap();
457        let parent = id.parent().unwrap();
458        assert_eq!(parent.to_string(), "1a");
459    }
460
461    #[test]
462    fn test_luhmann_next_sibling() {
463        let id = LuhmannId::parse("1a").unwrap();
464        let next = id.next_sibling().unwrap();
465        assert_eq!(next.to_string(), "1b");
466
467        let id2 = LuhmannId::parse("1").unwrap();
468        let next2 = id2.next_sibling().unwrap();
469        assert_eq!(next2.to_string(), "2");
470    }
471
472    #[test]
473    fn test_luhmann_first_child() {
474        let id = LuhmannId::parse("1").unwrap();
475        let child = id.first_child();
476        assert_eq!(child.to_string(), "1a");
477
478        let id2 = LuhmannId::parse("1a").unwrap();
479        let child2 = id2.first_child();
480        assert_eq!(child2.to_string(), "1a1");
481    }
482}