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    /// Get the level/depth of this ID
111    pub fn level(&self) -> usize {
112        self.parts.len()
113    }
114
115    /// Check if this ID is a descendant of another
116    pub fn is_descendant_of(&self, other: &Self) -> bool {
117        if other.parts.len() >= self.parts.len() {
118            return false;
119        }
120        self.parts[..other.parts.len()] == other.parts[..]
121    }
122}
123
124impl std::fmt::Display for LuhmannId {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        for part in &self.parts {
127            match part {
128                LuhmannPart::Number(n) => write!(f, "{}", n)?,
129                LuhmannPart::Letter(c) => write!(f, "{}", c)?,
130            }
131        }
132        Ok(())
133    }
134}
135
136impl std::str::FromStr for LuhmannId {
137    type Err = String;
138
139    fn from_str(s: &str) -> Result<Self, Self::Err> {
140        Self::parse(s).ok_or_else(|| format!("Invalid Luhmann ID: {}", s))
141    }
142}
143
144/// Simple link type - just "references" with optional context
145/// The Luhmann ID provides implicit structure (hierarchy, sequence)
146#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
147#[serde(rename_all = "snake_case")]
148pub enum LinkType {
149    /// General reference/link between notes
150    References,
151}
152
153/// A Zettelkasten-style atomic note
154/// The LuhmannId IS the note ID - no UUIDs
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
156pub struct Note {
157    pub id: NoteId, // This is now a LuhmannId
158    pub title: String,
159    pub content: String,
160    pub tags: Vec<String>,
161    pub agent_id: Option<String>, // For jots - associates note with an agent
162    pub created_at: Timestamp,
163    pub updated_at: Timestamp,
164}
165
166impl Note {
167    pub fn new(id: LuhmannId, title: impl Into<String>, content: impl Into<String>) -> Self {
168        let now = Utc::now();
169        Self {
170            id,
171            title: title.into(),
172            content: content.into(),
173            tags: Vec::new(),
174            agent_id: None,
175            created_at: now,
176            updated_at: now,
177        }
178    }
179
180    pub fn to_node(&self) -> Node {
181        let mut props = Properties::new();
182        props.insert(
183            "title".to_string(),
184            PropertyValue::String(self.title.clone()),
185        );
186        props.insert(
187            "content".to_string(),
188            PropertyValue::String(self.content.clone()),
189        );
190        // Store the Luhmann ID as a property as well for easy lookup
191        props.insert(
192            "luhmann_id".to_string(),
193            PropertyValue::String(self.id.to_string()),
194        );
195        props.insert(
196            "tags".to_string(),
197            PropertyValue::List(
198                self.tags
199                    .iter()
200                    .map(|t| PropertyValue::String(t.clone()))
201                    .collect(),
202            ),
203        );
204        if let Some(ref agent_id) = self.agent_id {
205            props.insert(
206                "agent_id".to_string(),
207                PropertyValue::String(agent_id.clone()),
208            );
209        }
210
211        // Convert LuhmannId to a deterministic Node ID string
212        let node_id = crate::domain::string_to_node_id(&self.id.to_string());
213        let mut node = Node::new("note", props);
214        node.id = node_id;
215        node.created_at = self.created_at;
216        node.updated_at = self.updated_at;
217        node
218    }
219
220    pub fn from_node(node: &Node) -> Option<Self> {
221        if node.node_type != "note" {
222            return None;
223        }
224
225        let title = node.get_property("title")?.as_str()?.to_string();
226        let content = node.get_property("content")?.as_str()?.to_string();
227
228        // Parse LuhmannId from the luhmann_id property
229        let luhmann_id = node
230            .get_property("luhmann_id")
231            .and_then(|v| v.as_str())
232            .and_then(|s| LuhmannId::parse(s))?;
233
234        let tags = node
235            .get_property("tags")
236            .and_then(|v| match v {
237                PropertyValue::List(list) => Some(
238                    list.iter()
239                        .filter_map(|item| item.as_str().map(String::from))
240                        .collect(),
241                ),
242                _ => None,
243            })
244            .unwrap_or_default();
245
246        let agent_id = node
247            .get_property("agent_id")
248            .and_then(|v| v.as_str())
249            .map(String::from);
250
251        Some(Self {
252            id: luhmann_id,
253            title,
254            content,
255            tags,
256            agent_id,
257            created_at: node.created_at,
258            updated_at: node.updated_at,
259        })
260    }
261}
262
263/// A note link (relationship between two notes)
264/// Uses LuhmannIds directly
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
266pub struct NoteLink {
267    pub from_note_id: NoteId,
268    pub to_note_id: NoteId,
269    pub link_type: LinkType,
270    pub context: Option<String>,
271}
272
273impl NoteLink {
274    pub fn new(
275        from_note_id: NoteId,
276        to_note_id: NoteId,
277        link_type: LinkType,
278        context: Option<String>,
279    ) -> Self {
280        Self {
281            from_note_id,
282            to_note_id,
283            link_type,
284            context,
285        }
286    }
287}
288
289/// Simple counter for generating next main topic IDs
290/// Stored as a special node in the graph
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct NoteCounter {
293    pub next_main_id: u32,
294    pub created_at: Timestamp,
295}
296
297impl NoteCounter {
298    pub fn new() -> Self {
299        Self {
300            next_main_id: 1,
301            created_at: Utc::now(),
302        }
303    }
304
305    pub fn to_node(&self) -> Node {
306        let mut props = Properties::new();
307        props.insert(
308            "next_main_id".to_string(),
309            PropertyValue::Integer(self.next_main_id as i64),
310        );
311
312        let mut node = Node::new("note_counter", props);
313        // Use a fixed node ID for the counter
314        node.id = crate::domain::string_to_node_id("__kb_counter__");
315        node.created_at = self.created_at;
316        node
317    }
318
319    pub fn from_node(node: &Node) -> Option<Self> {
320        if node.node_type != "note_counter" {
321            return None;
322        }
323
324        let next_main_id = node
325            .get_property("next_main_id")
326            .and_then(|v| match v {
327                PropertyValue::Integer(n) => Some(*n as u32),
328                _ => Some(1),
329            })
330            .unwrap_or(1);
331
332        Some(Self {
333            next_main_id,
334            created_at: node.created_at,
335        })
336    }
337
338    /// Get and increment the next main topic ID
339    pub fn next_main_topic_id(&mut self) -> LuhmannId {
340        let id = LuhmannId {
341            parts: vec![LuhmannPart::Number(self.next_main_id)],
342        };
343        self.next_main_id += 1;
344        id
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_luhmann_id_parsing() {
354        let id = LuhmannId::parse("1a2b").unwrap();
355        assert_eq!(id.parts.len(), 4);
356        assert!(matches!(id.parts[0], LuhmannPart::Number(1)));
357        assert!(matches!(id.parts[1], LuhmannPart::Letter('a')));
358        assert!(matches!(id.parts[2], LuhmannPart::Number(2)));
359        assert!(matches!(id.parts[3], LuhmannPart::Letter('b')));
360    }
361
362    #[test]
363    fn test_luhmann_id_display() {
364        let id = LuhmannId::parse("1a2").unwrap();
365        assert_eq!(id.to_string(), "1a2");
366    }
367
368    #[test]
369    fn test_luhmann_parent() {
370        let id = LuhmannId::parse("1a2").unwrap();
371        let parent = id.parent().unwrap();
372        assert_eq!(parent.to_string(), "1a");
373    }
374
375    #[test]
376    fn test_luhmann_next_sibling() {
377        let id = LuhmannId::parse("1a").unwrap();
378        let next = id.next_sibling().unwrap();
379        assert_eq!(next.to_string(), "1b");
380
381        let id2 = LuhmannId::parse("1").unwrap();
382        let next2 = id2.next_sibling().unwrap();
383        assert_eq!(next2.to_string(), "2");
384    }
385
386    #[test]
387    fn test_luhmann_first_child() {
388        let id = LuhmannId::parse("1").unwrap();
389        let child = id.first_child();
390        assert_eq!(child.to_string(), "1a");
391
392        let id2 = LuhmannId::parse("1a").unwrap();
393        let child2 = id2.first_child();
394        assert_eq!(child2.to_string(), "1a1");
395    }
396}