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    /// Optional lightweight catalog description for Orchestrator routing
25    ///
26    /// This is a concise (1-2 sentence) summary used by orchestrators to select
27    /// the appropriate agent. The full expertise details are rendered via `to_prompt()`
28    /// for LLM consumption.
29    ///
30    /// If not provided, it will be auto-generated from the first fragment content
31    /// (typically starts with "You are XXX" or "XXX Agent...").
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub description: Option<String>,
34
35    /// Tags for search and grouping (e.g., ["lang:rust", "role:reviewer", "style:friendly"])
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub tags: Vec<String>,
38
39    /// Knowledge and capability components (weighted)
40    pub content: Vec<WeightedFragment>,
41}
42
43impl Expertise {
44    /// Create a new expertise profile
45    ///
46    /// # Arguments
47    ///
48    /// * `id` - Unique identifier for this expertise
49    /// * `version` - Version string (e.g., "1.0.0")
50    ///
51    /// # Description Auto-generation
52    ///
53    /// The `description` field is optional. If not set via [`with_description()`](Self::with_description),
54    /// it will be auto-generated from the first fragment's content when needed.
55    /// Typical patterns include:
56    /// - "You are a Rust expert..."
57    /// - "Senior software engineer specialized in..."
58    /// - "Code reviewer with focus on..."
59    ///
60    /// # Example
61    ///
62    /// ```
63    /// use llm_toolkit_expertise::Expertise;
64    ///
65    /// let expertise = Expertise::new("rust-expert", "1.0.0");
66    /// ```
67    pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
68        Self {
69            id: id.into(),
70            version: version.into(),
71            description: None,
72            tags: Vec::new(),
73            content: Vec::new(),
74        }
75    }
76
77    /// Set an explicit description for catalog/routing purposes
78    ///
79    /// This overrides the auto-generated description. Use this when you want
80    /// a specific concise summary for orchestrator routing that differs from
81    /// the first fragment's content.
82    ///
83    /// # Example
84    ///
85    /// ```
86    /// use llm_toolkit_expertise::Expertise;
87    ///
88    /// let expertise = Expertise::new("rust-expert", "1.0.0")
89    ///     .with_description("Expert Rust developer and code reviewer");
90    /// ```
91    pub fn with_description(mut self, description: impl Into<String>) -> Self {
92        self.description = Some(description.into());
93        self
94    }
95
96    /// Add a tag
97    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
98        self.tags.push(tag.into());
99        self
100    }
101
102    /// Add tags
103    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
104        self.tags.extend(tags);
105        self
106    }
107
108    /// Add a weighted fragment
109    pub fn with_fragment(mut self, fragment: WeightedFragment) -> Self {
110        self.content.push(fragment);
111        self
112    }
113
114    /// Get the description, auto-generating if not explicitly set
115    ///
116    /// If no explicit description was set via [`with_description()`](Self::with_description),
117    /// this method generates one from the first fragment's content. It extracts the first
118    /// ~100 characters, which typically captures patterns like:
119    /// - "You are a Rust expert..."
120    /// - "Senior software engineer specialized in..."
121    ///
122    /// Returns an empty string if there are no fragments.
123    pub fn get_description(&self) -> String {
124        if let Some(desc) = &self.description {
125            return desc.clone();
126        }
127
128        // Auto-generate from first fragment
129        if let Some(first_fragment) = self.content.first() {
130            let content = match &first_fragment.fragment {
131                KnowledgeFragment::Text(text) => text.clone(),
132                KnowledgeFragment::Logic { instruction, .. } => instruction.clone(),
133                KnowledgeFragment::Guideline { rule, .. } => rule.clone(),
134                KnowledgeFragment::QualityStandard { criteria, .. } => criteria
135                    .first()
136                    .cloned()
137                    .unwrap_or_else(|| format!("{} v{}", self.id, self.version)),
138                _ => {
139                    // For ToolDefinition and other types, look for next usable fragment
140                    self.content
141                        .iter()
142                        .skip(1)
143                        .find_map(|wf| match &wf.fragment {
144                            KnowledgeFragment::Text(t) => Some(t.clone()),
145                            KnowledgeFragment::Logic { instruction, .. } => {
146                                Some(instruction.clone())
147                            }
148                            KnowledgeFragment::Guideline { rule, .. } => Some(rule.clone()),
149                            _ => None,
150                        })
151                        .unwrap_or_else(|| format!("{} v{}", self.id, self.version))
152                }
153            };
154
155            // Take first ~100 chars or first sentence
156            let truncated = content.chars().take(100).collect::<String>();
157            if truncated.len() < content.len() {
158                format!("{}...", truncated.trim_end())
159            } else {
160                truncated
161            }
162        } else {
163            // No fragments, use id/version
164            format!("{} v{}", self.id, self.version)
165        }
166    }
167
168    /// Extract tool definitions as capability names
169    ///
170    /// This method scans all fragments and extracts tool names from `ToolDefinition` fragments.
171    /// Returns a vector of capability identifiers that can be used for agent registration.
172    ///
173    /// Note: This is a basic implementation that extracts tool names from JSON schemas.
174    /// For use with llm-toolkit's `Capability` type, enable the "integration" feature.
175    pub fn extract_tool_names(&self) -> Vec<String> {
176        self.content
177            .iter()
178            .filter_map(|wf| match &wf.fragment {
179                KnowledgeFragment::ToolDefinition(tool_json) => {
180                    // Try to extract a name from the tool definition
181                    tool_json
182                        .get("name")
183                        .and_then(|v| v.as_str())
184                        .map(|s| s.to_string())
185                        .or_else(|| {
186                            // Fallback: use the type field if no name
187                            tool_json
188                                .get("type")
189                                .and_then(|v| v.as_str())
190                                .map(|s| s.to_string())
191                        })
192                }
193                _ => None,
194            })
195            .collect()
196    }
197
198    /// Generate a single prompt string from all fragments
199    ///
200    /// Fragments are ordered by priority (Critical → High → Normal → Low)
201    pub fn to_prompt(&self) -> String {
202        self.to_prompt_with_context(&ContextMatcher::default())
203    }
204
205    /// Generate a prompt string with render context filtering (Phase 2)
206    ///
207    /// This is the new context-aware rendering API that supports multiple user states
208    /// and improved context matching.
209    ///
210    /// # Examples
211    ///
212    /// ```
213    /// use llm_toolkit_expertise::{Expertise, WeightedFragment, KnowledgeFragment};
214    /// use llm_toolkit_expertise::render::RenderContext;
215    /// use llm_toolkit_expertise::context::TaskHealth;
216    ///
217    /// let expertise = Expertise::new("test", "1.0")
218    ///     .with_fragment(WeightedFragment::new(
219    ///         KnowledgeFragment::Text("Test".to_string())
220    ///     ));
221    ///
222    /// let context = RenderContext::new()
223    ///     .with_task_type("security-review")
224    ///     .with_task_health(TaskHealth::AtRisk);
225    ///
226    /// let prompt = expertise.to_prompt_with_render_context(&context);
227    /// ```
228    pub fn to_prompt_with_render_context(&self, context: &crate::render::RenderContext) -> String {
229        // Convert RenderContext to ContextMatcher for now
230        // In the future, we can refactor to use RenderContext directly
231        self.to_prompt_with_context(&context.to_context_matcher())
232    }
233
234    /// Generate a prompt string with context filtering (legacy API)
235    ///
236    /// Only includes fragments that match the given context conditions.
237    ///
238    /// **Note**: Consider using `to_prompt_with_render_context()` for the new API
239    /// with improved context matching.
240    pub fn to_prompt_with_context(&self, context: &ContextMatcher) -> String {
241        let mut result = format!("# Expertise: {} (v{})\n\n", self.id, self.version);
242
243        if !self.tags.is_empty() {
244            result.push_str("**Tags:** ");
245            result.push_str(&self.tags.join(", "));
246            result.push_str("\n\n");
247        }
248
249        result.push_str("---\n\n");
250
251        // Sort fragments by priority (highest first)
252        let mut sorted_fragments: Vec<_> = self
253            .content
254            .iter()
255            .filter(|f| f.context.matches(context))
256            .collect();
257        sorted_fragments.sort_by(|a, b| b.priority.cmp(&a.priority));
258
259        // Group by priority
260        let mut current_priority: Option<Priority> = None;
261        for weighted in sorted_fragments {
262            // Add priority header if changed
263            if current_priority != Some(weighted.priority) {
264                current_priority = Some(weighted.priority);
265                result.push_str(&format!("## Priority: {}\n\n", weighted.priority.label()));
266            }
267
268            // Add fragment content
269            result.push_str(&weighted.fragment.to_prompt());
270            result.push('\n');
271        }
272
273        result
274    }
275
276    /// Generate a Mermaid graph representation
277    pub fn to_mermaid(&self) -> String {
278        let mut result = String::from("graph TD\n");
279
280        // Root node (expertise)
281        result.push_str(&format!("    ROOT[\"Expertise: {}\"]\n", self.id));
282
283        // Add tag nodes if present
284        if !self.tags.is_empty() {
285            result.push_str("    TAGS[\"Tags\"]\n");
286            result.push_str("    ROOT --> TAGS\n");
287            for (i, tag) in self.tags.iter().enumerate() {
288                let tag_id = format!("TAG{}", i);
289                result.push_str(&format!("    {}[\"{}\"]\n", tag_id, tag));
290                result.push_str(&format!("    TAGS --> {}\n", tag_id));
291            }
292        }
293
294        // Add fragment nodes
295        for (i, weighted) in self.content.iter().enumerate() {
296            let node_id = format!("F{}", i);
297            let summary = weighted.fragment.summary();
298            let type_label = weighted.fragment.type_label();
299
300            // Node with priority styling
301            let style_class = match weighted.priority {
302                Priority::Critical => ":::critical",
303                Priority::High => ":::high",
304                Priority::Normal => ":::normal",
305                Priority::Low => ":::low",
306            };
307
308            result.push_str(&format!(
309                "    {}[\"{} [{}]: {}\"]{}\n",
310                node_id,
311                weighted.priority.label(),
312                type_label,
313                summary,
314                style_class
315            ));
316            result.push_str(&format!("    ROOT --> {}\n", node_id));
317
318            // Add context info if conditional
319            if let ContextProfile::Conditional {
320                task_types,
321                user_states,
322                task_health,
323            } = &weighted.context
324            {
325                let context_id = format!("C{}", i);
326                let mut context_parts = Vec::new();
327
328                if !task_types.is_empty() {
329                    context_parts.push(format!("Tasks: {}", task_types.join(", ")));
330                }
331                if !user_states.is_empty() {
332                    context_parts.push(format!("States: {}", user_states.join(", ")));
333                }
334                if let Some(health) = task_health {
335                    context_parts.push(format!("Health: {}", health.label()));
336                }
337
338                if !context_parts.is_empty() {
339                    result.push_str(&format!(
340                        "    {}[\"Context: {}\"]\n",
341                        context_id,
342                        context_parts.join("; ")
343                    ));
344                    result.push_str(&format!("    {} -.-> {}\n", node_id, context_id));
345                }
346            }
347        }
348
349        // Add styling
350        result.push_str("\n    classDef critical fill:#ff6b6b,stroke:#c92a2a,stroke-width:3px\n");
351        result.push_str("    classDef high fill:#ffd93d,stroke:#f08c00,stroke-width:2px\n");
352        result.push_str("    classDef normal fill:#a0e7e5,stroke:#4ecdc4,stroke-width:1px\n");
353        result.push_str("    classDef low fill:#e0e0e0,stroke:#999,stroke-width:1px\n");
354
355        result
356    }
357
358    /// Generate a simple tree representation
359    pub fn to_tree(&self) -> String {
360        let mut result = format!("Expertise: {} (v{})\n", self.id, self.version);
361
362        if !self.tags.is_empty() {
363            result.push_str(&format!("├─ Tags: {}\n", self.tags.join(", ")));
364        }
365
366        result.push_str("└─ Content:\n");
367
368        // Sort by priority
369        let mut sorted_fragments: Vec<_> = self.content.iter().collect();
370        sorted_fragments.sort_by(|a, b| b.priority.cmp(&a.priority));
371
372        for (i, weighted) in sorted_fragments.iter().enumerate() {
373            let is_last = i == sorted_fragments.len() - 1;
374            let prefix = if is_last { "   └─" } else { "   ├─" };
375
376            let summary = weighted.fragment.summary();
377            let type_label = weighted.fragment.type_label();
378
379            result.push_str(&format!(
380                "{} [{}] {}: {}\n",
381                prefix,
382                weighted.priority.label(),
383                type_label,
384                summary
385            ));
386
387            // Add context info
388            if let ContextProfile::Conditional {
389                task_types,
390                user_states,
391                task_health,
392            } = &weighted.context
393            {
394                let sub_prefix = if is_last { "      " } else { "   │  " };
395                if !task_types.is_empty() {
396                    result.push_str(&format!(
397                        "{} └─ Tasks: {}\n",
398                        sub_prefix,
399                        task_types.join(", ")
400                    ));
401                }
402                if !user_states.is_empty() {
403                    result.push_str(&format!(
404                        "{} └─ States: {}\n",
405                        sub_prefix,
406                        user_states.join(", ")
407                    ));
408                }
409                if let Some(health) = task_health {
410                    result.push_str(&format!(
411                        "{} └─ Health: {} {}\n",
412                        sub_prefix,
413                        health.emoji(),
414                        health.label()
415                    ));
416                }
417            }
418        }
419
420        result
421    }
422}
423
424/// WeightedFragment: Knowledge entity with metadata
425///
426/// Combines a knowledge fragment with its priority and activation context.
427#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
428pub struct WeightedFragment {
429    /// Priority: Controls enforcement strength and ordering
430    #[serde(default)]
431    pub priority: Priority,
432
433    /// Context: Activation conditions
434    #[serde(default)]
435    pub context: ContextProfile,
436
437    /// Fragment: The actual knowledge content
438    pub fragment: KnowledgeFragment,
439}
440
441impl WeightedFragment {
442    /// Create a new weighted fragment with default priority and always-active context
443    pub fn new(fragment: KnowledgeFragment) -> Self {
444        Self {
445            priority: Priority::default(),
446            context: ContextProfile::default(),
447            fragment,
448        }
449    }
450
451    /// Set priority
452    pub fn with_priority(mut self, priority: Priority) -> Self {
453        self.priority = priority;
454        self
455    }
456
457    /// Set context profile
458    pub fn with_context(mut self, context: ContextProfile) -> Self {
459        self.context = context;
460        self
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_expertise_builder() {
470        let expertise = Expertise::new("test", "1.0")
471            .with_description("Test description")
472            .with_tag("test-tag")
473            .with_fragment(WeightedFragment::new(KnowledgeFragment::Text(
474                "Test content".to_string(),
475            )));
476
477        assert_eq!(expertise.id, "test");
478        assert_eq!(expertise.version, "1.0");
479        assert_eq!(expertise.description, Some("Test description".to_string()));
480        assert_eq!(expertise.tags.len(), 1);
481        assert_eq!(expertise.content.len(), 1);
482    }
483
484    #[test]
485    fn test_to_prompt_ordering() {
486        let expertise = Expertise::new("test", "1.0")
487            .with_fragment(
488                WeightedFragment::new(KnowledgeFragment::Text("Low priority".to_string()))
489                    .with_priority(Priority::Low),
490            )
491            .with_fragment(
492                WeightedFragment::new(KnowledgeFragment::Text("Critical priority".to_string()))
493                    .with_priority(Priority::Critical),
494            )
495            .with_fragment(
496                WeightedFragment::new(KnowledgeFragment::Text("Normal priority".to_string()))
497                    .with_priority(Priority::Normal),
498            );
499
500        let prompt = expertise.to_prompt();
501
502        // Critical should appear before Normal, Normal before Low
503        let critical_pos = prompt.find("Critical priority").unwrap();
504        let normal_pos = prompt.find("Normal priority").unwrap();
505        let low_pos = prompt.find("Low priority").unwrap();
506
507        assert!(critical_pos < normal_pos);
508        assert!(normal_pos < low_pos);
509    }
510
511    #[test]
512    fn test_context_filtering() {
513        let expertise = Expertise::new("test", "1.0")
514            .with_fragment(
515                WeightedFragment::new(KnowledgeFragment::Text("Always visible".to_string()))
516                    .with_context(ContextProfile::Always),
517            )
518            .with_fragment(
519                WeightedFragment::new(KnowledgeFragment::Text("Debug only".to_string()))
520                    .with_context(ContextProfile::Conditional {
521                        task_types: vec!["Debug".to_string()],
522                        user_states: vec![],
523                        task_health: None,
524                    }),
525            );
526
527        // Without debug context
528        let prompt1 = expertise.to_prompt_with_context(&ContextMatcher::new());
529        assert!(prompt1.contains("Always visible"));
530        assert!(!prompt1.contains("Debug only"));
531
532        // With debug context
533        let prompt2 =
534            expertise.to_prompt_with_context(&ContextMatcher::new().with_task_type("Debug"));
535        assert!(prompt2.contains("Always visible"));
536        assert!(prompt2.contains("Debug only"));
537    }
538
539    #[test]
540    fn test_to_tree() {
541        let expertise = Expertise::new("test", "1.0")
542            .with_tag("test-tag")
543            .with_fragment(WeightedFragment::new(KnowledgeFragment::Text(
544                "Test content".to_string(),
545            )));
546
547        let tree = expertise.to_tree();
548        assert!(tree.contains("Expertise: test"));
549        assert!(tree.contains("test-tag"));
550        assert!(tree.contains("Test content"));
551    }
552
553    #[test]
554    fn test_to_mermaid() {
555        let expertise = Expertise::new("test", "1.0").with_fragment(WeightedFragment::new(
556            KnowledgeFragment::Text("Test content".to_string()),
557        ));
558
559        let mermaid = expertise.to_mermaid();
560        assert!(mermaid.contains("graph TD"));
561        assert!(mermaid.contains("Expertise: test"));
562        assert!(mermaid.contains("Test content"));
563    }
564}