Skip to main content

agent_office/services/kb/
domain.rs

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