llm_toolkit_expertise/
types.rs

1//! Core expertise types and structures.
2//!
3//! This module defines the main Expertise type and related structures for
4//! composing agent capabilities from weighted knowledge fragments.
5
6use crate::context::{ContextMatcher, ContextProfile, Priority};
7use crate::fragment::KnowledgeFragment;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Expertise: Agent capability package (Graph node)
12///
13/// Represents a complete agent expertise profile composed of weighted
14/// knowledge fragments. Uses composition instead of inheritance for
15/// flexible capability mixing.
16#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
17pub struct Expertise {
18    /// Unique identifier
19    pub id: String,
20
21    /// Version string
22    pub version: String,
23
24    /// Tags for search and grouping (e.g., ["lang:rust", "role:reviewer", "style:friendly"])
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub tags: Vec<String>,
27
28    /// Knowledge and capability components (weighted)
29    pub content: Vec<WeightedFragment>,
30}
31
32impl Expertise {
33    /// Create a new expertise profile
34    pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
35        Self {
36            id: id.into(),
37            version: version.into(),
38            tags: Vec::new(),
39            content: Vec::new(),
40        }
41    }
42
43    /// Add a tag
44    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
45        self.tags.push(tag.into());
46        self
47    }
48
49    /// Add tags
50    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
51        self.tags.extend(tags);
52        self
53    }
54
55    /// Add a weighted fragment
56    pub fn with_fragment(mut self, fragment: WeightedFragment) -> Self {
57        self.content.push(fragment);
58        self
59    }
60
61    /// Generate a single prompt string from all fragments
62    ///
63    /// Fragments are ordered by priority (Critical → High → Normal → Low)
64    pub fn to_prompt(&self) -> String {
65        self.to_prompt_with_context(&ContextMatcher::default())
66    }
67
68    /// Generate a prompt string with context filtering
69    ///
70    /// Only includes fragments that match the given context conditions
71    pub fn to_prompt_with_context(&self, context: &ContextMatcher) -> String {
72        let mut result = format!("# Expertise: {} (v{})\n\n", self.id, self.version);
73
74        if !self.tags.is_empty() {
75            result.push_str("**Tags:** ");
76            result.push_str(&self.tags.join(", "));
77            result.push_str("\n\n");
78        }
79
80        result.push_str("---\n\n");
81
82        // Sort fragments by priority (highest first)
83        let mut sorted_fragments: Vec<_> = self
84            .content
85            .iter()
86            .filter(|f| f.context.matches(context))
87            .collect();
88        sorted_fragments.sort_by(|a, b| b.priority.cmp(&a.priority));
89
90        // Group by priority
91        let mut current_priority: Option<Priority> = None;
92        for weighted in sorted_fragments {
93            // Add priority header if changed
94            if current_priority != Some(weighted.priority) {
95                current_priority = Some(weighted.priority);
96                result.push_str(&format!("## Priority: {}\n\n", weighted.priority.label()));
97            }
98
99            // Add fragment content
100            result.push_str(&weighted.fragment.to_prompt());
101            result.push('\n');
102        }
103
104        result
105    }
106
107    /// Generate a Mermaid graph representation
108    pub fn to_mermaid(&self) -> String {
109        let mut result = String::from("graph TD\n");
110
111        // Root node (expertise)
112        result.push_str(&format!("    ROOT[\"Expertise: {}\"]\n", self.id));
113
114        // Add tag nodes if present
115        if !self.tags.is_empty() {
116            result.push_str("    TAGS[\"Tags\"]\n");
117            result.push_str("    ROOT --> TAGS\n");
118            for (i, tag) in self.tags.iter().enumerate() {
119                let tag_id = format!("TAG{}", i);
120                result.push_str(&format!("    {}[\"{}\"]\n", tag_id, tag));
121                result.push_str(&format!("    TAGS --> {}\n", tag_id));
122            }
123        }
124
125        // Add fragment nodes
126        for (i, weighted) in self.content.iter().enumerate() {
127            let node_id = format!("F{}", i);
128            let summary = weighted.fragment.summary();
129            let type_label = weighted.fragment.type_label();
130
131            // Node with priority styling
132            let style_class = match weighted.priority {
133                Priority::Critical => ":::critical",
134                Priority::High => ":::high",
135                Priority::Normal => ":::normal",
136                Priority::Low => ":::low",
137            };
138
139            result.push_str(&format!(
140                "    {}[\"{} [{}]: {}\"]{}\n",
141                node_id,
142                weighted.priority.label(),
143                type_label,
144                summary,
145                style_class
146            ));
147            result.push_str(&format!("    ROOT --> {}\n", node_id));
148
149            // Add context info if conditional
150            if let ContextProfile::Conditional {
151                task_types,
152                user_states,
153                task_health,
154            } = &weighted.context
155            {
156                let context_id = format!("C{}", i);
157                let mut context_parts = Vec::new();
158
159                if !task_types.is_empty() {
160                    context_parts.push(format!("Tasks: {}", task_types.join(", ")));
161                }
162                if !user_states.is_empty() {
163                    context_parts.push(format!("States: {}", user_states.join(", ")));
164                }
165                if let Some(health) = task_health {
166                    context_parts.push(format!("Health: {}", health.label()));
167                }
168
169                if !context_parts.is_empty() {
170                    result.push_str(&format!(
171                        "    {}[\"Context: {}\"]\n",
172                        context_id,
173                        context_parts.join("; ")
174                    ));
175                    result.push_str(&format!("    {} -.-> {}\n", node_id, context_id));
176                }
177            }
178        }
179
180        // Add styling
181        result.push_str("\n    classDef critical fill:#ff6b6b,stroke:#c92a2a,stroke-width:3px\n");
182        result.push_str("    classDef high fill:#ffd93d,stroke:#f08c00,stroke-width:2px\n");
183        result.push_str("    classDef normal fill:#a0e7e5,stroke:#4ecdc4,stroke-width:1px\n");
184        result.push_str("    classDef low fill:#e0e0e0,stroke:#999,stroke-width:1px\n");
185
186        result
187    }
188
189    /// Generate a simple tree representation
190    pub fn to_tree(&self) -> String {
191        let mut result = format!("Expertise: {} (v{})\n", self.id, self.version);
192
193        if !self.tags.is_empty() {
194            result.push_str(&format!("├─ Tags: {}\n", self.tags.join(", ")));
195        }
196
197        result.push_str("└─ Content:\n");
198
199        // Sort by priority
200        let mut sorted_fragments: Vec<_> = self.content.iter().collect();
201        sorted_fragments.sort_by(|a, b| b.priority.cmp(&a.priority));
202
203        for (i, weighted) in sorted_fragments.iter().enumerate() {
204            let is_last = i == sorted_fragments.len() - 1;
205            let prefix = if is_last { "   └─" } else { "   ├─" };
206
207            let summary = weighted.fragment.summary();
208            let type_label = weighted.fragment.type_label();
209
210            result.push_str(&format!(
211                "{} [{}] {}: {}\n",
212                prefix,
213                weighted.priority.label(),
214                type_label,
215                summary
216            ));
217
218            // Add context info
219            if let ContextProfile::Conditional {
220                task_types,
221                user_states,
222                task_health,
223            } = &weighted.context
224            {
225                let sub_prefix = if is_last { "      " } else { "   │  " };
226                if !task_types.is_empty() {
227                    result.push_str(&format!(
228                        "{} └─ Tasks: {}\n",
229                        sub_prefix,
230                        task_types.join(", ")
231                    ));
232                }
233                if !user_states.is_empty() {
234                    result.push_str(&format!(
235                        "{} └─ States: {}\n",
236                        sub_prefix,
237                        user_states.join(", ")
238                    ));
239                }
240                if let Some(health) = task_health {
241                    result.push_str(&format!(
242                        "{} └─ Health: {} {}\n",
243                        sub_prefix,
244                        health.emoji(),
245                        health.label()
246                    ));
247                }
248            }
249        }
250
251        result
252    }
253}
254
255/// WeightedFragment: Knowledge entity with metadata
256///
257/// Combines a knowledge fragment with its priority and activation context.
258#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
259pub struct WeightedFragment {
260    /// Priority: Controls enforcement strength and ordering
261    #[serde(default)]
262    pub priority: Priority,
263
264    /// Context: Activation conditions
265    #[serde(default)]
266    pub context: ContextProfile,
267
268    /// Fragment: The actual knowledge content
269    pub fragment: KnowledgeFragment,
270}
271
272impl WeightedFragment {
273    /// Create a new weighted fragment with default priority and always-active context
274    pub fn new(fragment: KnowledgeFragment) -> Self {
275        Self {
276            priority: Priority::default(),
277            context: ContextProfile::default(),
278            fragment,
279        }
280    }
281
282    /// Set priority
283    pub fn with_priority(mut self, priority: Priority) -> Self {
284        self.priority = priority;
285        self
286    }
287
288    /// Set context profile
289    pub fn with_context(mut self, context: ContextProfile) -> Self {
290        self.context = context;
291        self
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_expertise_builder() {
301        let expertise = Expertise::new("test", "1.0")
302            .with_tag("test-tag")
303            .with_fragment(WeightedFragment::new(KnowledgeFragment::Text(
304                "Test content".to_string(),
305            )));
306
307        assert_eq!(expertise.id, "test");
308        assert_eq!(expertise.version, "1.0");
309        assert_eq!(expertise.tags.len(), 1);
310        assert_eq!(expertise.content.len(), 1);
311    }
312
313    #[test]
314    fn test_to_prompt_ordering() {
315        let expertise = Expertise::new("test", "1.0")
316            .with_fragment(
317                WeightedFragment::new(KnowledgeFragment::Text("Low priority".to_string()))
318                    .with_priority(Priority::Low),
319            )
320            .with_fragment(
321                WeightedFragment::new(KnowledgeFragment::Text("Critical priority".to_string()))
322                    .with_priority(Priority::Critical),
323            )
324            .with_fragment(
325                WeightedFragment::new(KnowledgeFragment::Text("Normal priority".to_string()))
326                    .with_priority(Priority::Normal),
327            );
328
329        let prompt = expertise.to_prompt();
330
331        // Critical should appear before Normal, Normal before Low
332        let critical_pos = prompt.find("Critical priority").unwrap();
333        let normal_pos = prompt.find("Normal priority").unwrap();
334        let low_pos = prompt.find("Low priority").unwrap();
335
336        assert!(critical_pos < normal_pos);
337        assert!(normal_pos < low_pos);
338    }
339
340    #[test]
341    fn test_context_filtering() {
342        let expertise = Expertise::new("test", "1.0")
343            .with_fragment(
344                WeightedFragment::new(KnowledgeFragment::Text("Always visible".to_string()))
345                    .with_context(ContextProfile::Always),
346            )
347            .with_fragment(
348                WeightedFragment::new(KnowledgeFragment::Text("Debug only".to_string()))
349                    .with_context(ContextProfile::Conditional {
350                        task_types: vec!["Debug".to_string()],
351                        user_states: vec![],
352                        task_health: None,
353                    }),
354            );
355
356        // Without debug context
357        let prompt1 = expertise.to_prompt_with_context(&ContextMatcher::new());
358        assert!(prompt1.contains("Always visible"));
359        assert!(!prompt1.contains("Debug only"));
360
361        // With debug context
362        let prompt2 =
363            expertise.to_prompt_with_context(&ContextMatcher::new().with_task_type("Debug"));
364        assert!(prompt2.contains("Always visible"));
365        assert!(prompt2.contains("Debug only"));
366    }
367
368    #[test]
369    fn test_to_tree() {
370        let expertise = Expertise::new("test", "1.0")
371            .with_tag("test-tag")
372            .with_fragment(WeightedFragment::new(KnowledgeFragment::Text(
373                "Test content".to_string(),
374            )));
375
376        let tree = expertise.to_tree();
377        assert!(tree.contains("Expertise: test"));
378        assert!(tree.contains("test-tag"));
379        assert!(tree.contains("Test content"));
380    }
381
382    #[test]
383    fn test_to_mermaid() {
384        let expertise = Expertise::new("test", "1.0").with_fragment(WeightedFragment::new(
385            KnowledgeFragment::Text("Test content".to_string()),
386        ));
387
388        let mermaid = expertise.to_mermaid();
389        assert!(mermaid.contains("graph TD"));
390        assert!(mermaid.contains("Expertise: test"));
391        assert!(mermaid.contains("Test content"));
392    }
393}