pub mod fragment;
pub mod render;
pub use fragment::{Anchor, KnowledgeFragment};
pub use render::{ContextualPrompt, RenderContext};
use crate::agent::{Capability, ToExpertise};
use crate::context::{ContextProfile, Priority};
use crate::prompt::{PromptPart, ToPrompt};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Expertise {
pub id: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
pub content: Vec<WeightedFragment>,
}
impl Expertise {
pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
Self {
id: id.into(),
version: version.into(),
description: None,
tags: Vec::new(),
content: Vec::new(),
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags.extend(tags);
self
}
pub fn with_fragment(mut self, fragment: WeightedFragment) -> Self {
self.content.push(fragment);
self
}
pub fn get_description(&self) -> String {
if let Some(desc) = &self.description {
return desc.clone();
}
if let Some(first_fragment) = self.content.first() {
let content = match &first_fragment.fragment {
KnowledgeFragment::Text(text) => text.clone(),
KnowledgeFragment::Logic { instruction, .. } => instruction.clone(),
KnowledgeFragment::Guideline { rule, .. } => rule.clone(),
KnowledgeFragment::QualityStandard { criteria, .. } => criteria
.first()
.cloned()
.unwrap_or_else(|| format!("{} v{}", self.id, self.version)),
_ => {
self.content
.iter()
.skip(1)
.find_map(|wf| match &wf.fragment {
KnowledgeFragment::Text(t) => Some(t.clone()),
KnowledgeFragment::Logic { instruction, .. } => {
Some(instruction.clone())
}
KnowledgeFragment::Guideline { rule, .. } => Some(rule.clone()),
_ => None,
})
.unwrap_or_else(|| format!("{} v{}", self.id, self.version))
}
};
let truncated = content.chars().take(100).collect::<String>();
if truncated.len() < content.len() {
format!("{}...", truncated.trim_end())
} else {
truncated
}
} else {
format!("{} v{}", self.id, self.version)
}
}
pub fn auto_description_from_text(text: &str) -> String {
let truncated = text.chars().take(100).collect::<String>();
if truncated.len() < text.len() {
format!("{}...", truncated.trim_end())
} else {
truncated
}
}
pub fn extract_tool_names(&self) -> Vec<String> {
self.content
.iter()
.filter_map(|wf| match &wf.fragment {
KnowledgeFragment::ToolDefinition(tool_json) => {
tool_json
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
tool_json
.get("type")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
}
_ => None,
})
.collect()
}
pub fn to_prompt(&self) -> String {
self.to_prompt_with_context(&RenderContext::default())
}
pub fn to_prompt_with_context(&self, context: &RenderContext) -> String {
let mut result = format!("# Expertise: {} (v{})\n\n", self.id, self.version);
if !self.tags.is_empty() {
result.push_str("**Tags:** ");
result.push_str(&self.tags.join(", "));
result.push_str("\n\n");
}
result.push_str("---\n\n");
let mut sorted_fragments: Vec<_> = self
.content
.iter()
.filter(|f| context.matches(&f.context))
.collect();
sorted_fragments.sort_by(|a, b| b.priority.cmp(&a.priority));
let mut current_priority: Option<Priority> = None;
for weighted in sorted_fragments {
if current_priority != Some(weighted.priority) {
current_priority = Some(weighted.priority);
result.push_str(&format!("## Priority: {}\n\n", weighted.priority.label()));
}
result.push_str(&weighted.fragment.to_prompt());
result.push('\n');
}
result
}
pub fn to_mermaid(&self) -> String {
let mut result = String::from("graph TD\n");
result.push_str(&format!(" ROOT[\"Expertise: {}\"]\n", self.id));
if !self.tags.is_empty() {
result.push_str(" TAGS[\"Tags\"]\n");
result.push_str(" ROOT --> TAGS\n");
for (i, tag) in self.tags.iter().enumerate() {
let tag_id = format!("TAG{}", i);
result.push_str(&format!(" {}[\"{}\"]\n", tag_id, tag));
result.push_str(&format!(" TAGS --> {}\n", tag_id));
}
}
for (i, weighted) in self.content.iter().enumerate() {
let node_id = format!("F{}", i);
let summary = weighted.fragment.summary();
let type_label = weighted.fragment.type_label();
let style_class = match weighted.priority {
Priority::Critical => ":::critical",
Priority::High => ":::high",
Priority::Normal => ":::normal",
Priority::Low => ":::low",
};
result.push_str(&format!(
" {}[\"{} [{}]: {}\"]{}\n",
node_id,
weighted.priority.label(),
type_label,
summary,
style_class
));
result.push_str(&format!(" ROOT --> {}\n", node_id));
if let ContextProfile::Conditional {
task_types,
user_states,
task_health,
} = &weighted.context
{
let context_id = format!("C{}", i);
let mut context_parts = Vec::new();
if !task_types.is_empty() {
context_parts.push(format!("Tasks: {}", task_types.join(", ")));
}
if !user_states.is_empty() {
context_parts.push(format!("States: {}", user_states.join(", ")));
}
if let Some(health) = task_health {
context_parts.push(format!("Health: {}", health.label()));
}
if !context_parts.is_empty() {
result.push_str(&format!(
" {}[\"Context: {}\"]\n",
context_id,
context_parts.join("; ")
));
result.push_str(&format!(" {} -.-> {}\n", node_id, context_id));
}
}
}
result.push_str("\n classDef critical fill:#ff6b6b,stroke:#c92a2a,stroke-width:3px\n");
result.push_str(" classDef high fill:#ffd93d,stroke:#f08c00,stroke-width:2px\n");
result.push_str(" classDef normal fill:#a0e7e5,stroke:#4ecdc4,stroke-width:1px\n");
result.push_str(" classDef low fill:#e0e0e0,stroke:#999,stroke-width:1px\n");
result
}
pub fn to_tree(&self) -> String {
let mut result = format!("Expertise: {} (v{})\n", self.id, self.version);
if !self.tags.is_empty() {
result.push_str(&format!("├─ Tags: {}\n", self.tags.join(", ")));
}
result.push_str("└─ Content:\n");
let mut sorted_fragments: Vec<_> = self.content.iter().collect();
sorted_fragments.sort_by(|a, b| b.priority.cmp(&a.priority));
for (i, weighted) in sorted_fragments.iter().enumerate() {
let is_last = i == sorted_fragments.len() - 1;
let prefix = if is_last { " └─" } else { " ├─" };
let summary = weighted.fragment.summary();
let type_label = weighted.fragment.type_label();
result.push_str(&format!(
"{} [{}] {}: {}\n",
prefix,
weighted.priority.label(),
type_label,
summary
));
if let ContextProfile::Conditional {
task_types,
user_states,
task_health,
} = &weighted.context
{
let sub_prefix = if is_last { " " } else { " │ " };
if !task_types.is_empty() {
result.push_str(&format!(
"{} └─ Tasks: {}\n",
sub_prefix,
task_types.join(", ")
));
}
if !user_states.is_empty() {
result.push_str(&format!(
"{} └─ States: {}\n",
sub_prefix,
user_states.join(", ")
));
}
if let Some(health) = task_health {
result.push_str(&format!(
"{} └─ Health: {} {}\n",
sub_prefix,
health.emoji(),
health.label()
));
}
}
}
result
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WeightedFragment {
#[serde(default)]
pub priority: Priority,
#[serde(default)]
pub context: ContextProfile,
pub fragment: KnowledgeFragment,
}
impl WeightedFragment {
pub fn new(fragment: KnowledgeFragment) -> Self {
Self {
priority: Priority::default(),
context: ContextProfile::default(),
fragment,
}
}
pub fn with_priority(mut self, priority: Priority) -> Self {
self.priority = priority;
self
}
pub fn with_context(mut self, context: ContextProfile) -> Self {
self.context = context;
self
}
}
impl ToPrompt for Expertise {
fn to_prompt_parts(&self) -> Vec<PromptPart> {
let prompt_text = Expertise::to_prompt(self);
vec![PromptPart::Text(prompt_text)]
}
fn to_prompt(&self) -> String {
Expertise::to_prompt(self)
}
}
impl ToExpertise for Expertise {
fn description(&self) -> &str {
if let Some(desc) = &self.description {
return desc;
}
&self.id
}
fn capabilities(&self) -> Vec<Capability> {
self.extract_tool_names()
.into_iter()
.map(Capability::new)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expertise_builder() {
let expertise = Expertise::new("test", "1.0")
.with_description("Test description")
.with_tag("test-tag")
.with_fragment(WeightedFragment::new(KnowledgeFragment::Text(
"Test content".to_string(),
)));
assert_eq!(expertise.id, "test");
assert_eq!(expertise.version, "1.0");
assert_eq!(expertise.description, Some("Test description".to_string()));
assert_eq!(expertise.tags.len(), 1);
assert_eq!(expertise.content.len(), 1);
}
#[test]
fn test_to_prompt_ordering() {
let expertise = Expertise::new("test", "1.0")
.with_fragment(
WeightedFragment::new(KnowledgeFragment::Text("Low priority".to_string()))
.with_priority(Priority::Low),
)
.with_fragment(
WeightedFragment::new(KnowledgeFragment::Text("Critical priority".to_string()))
.with_priority(Priority::Critical),
)
.with_fragment(
WeightedFragment::new(KnowledgeFragment::Text("Normal priority".to_string()))
.with_priority(Priority::Normal),
);
let prompt = expertise.to_prompt();
let critical_pos = prompt.find("Critical priority").unwrap();
let normal_pos = prompt.find("Normal priority").unwrap();
let low_pos = prompt.find("Low priority").unwrap();
assert!(critical_pos < normal_pos);
assert!(normal_pos < low_pos);
}
#[test]
fn test_context_filtering() {
let expertise = Expertise::new("test", "1.0")
.with_fragment(
WeightedFragment::new(KnowledgeFragment::Text("Always visible".to_string()))
.with_context(ContextProfile::Always),
)
.with_fragment(
WeightedFragment::new(KnowledgeFragment::Text("Debug only".to_string()))
.with_context(ContextProfile::Conditional {
task_types: vec!["Debug".to_string()],
user_states: vec![],
task_health: None,
}),
);
let prompt1 = expertise.to_prompt_with_context(&RenderContext::new());
assert!(prompt1.contains("Always visible"));
assert!(!prompt1.contains("Debug only"));
let prompt2 =
expertise.to_prompt_with_context(&RenderContext::new().with_task_type("Debug"));
assert!(prompt2.contains("Always visible"));
assert!(prompt2.contains("Debug only"));
}
#[test]
fn test_to_tree() {
let expertise = Expertise::new("test", "1.0")
.with_tag("test-tag")
.with_fragment(WeightedFragment::new(KnowledgeFragment::Text(
"Test content".to_string(),
)));
let tree = expertise.to_tree();
assert!(tree.contains("Expertise: test"));
assert!(tree.contains("test-tag"));
assert!(tree.contains("Test content"));
}
#[test]
fn test_to_mermaid() {
let expertise = Expertise::new("test", "1.0").with_fragment(WeightedFragment::new(
KnowledgeFragment::Text("Test content".to_string()),
));
let mermaid = expertise.to_mermaid();
assert!(mermaid.contains("graph TD"));
assert!(mermaid.contains("Expertise: test"));
assert!(mermaid.contains("Test content"));
}
#[test]
fn test_to_prompt_trait() {
let expertise = Expertise::new("test", "1.0").with_fragment(WeightedFragment::new(
KnowledgeFragment::Text("Test content".to_string()),
));
let result = ToPrompt::to_prompt(&expertise);
assert!(result.contains("Expertise: test"));
assert!(result.contains("Test content"));
}
#[test]
fn test_to_prompt_parts() {
let expertise = Expertise::new("test", "1.0").with_fragment(WeightedFragment::new(
KnowledgeFragment::Text("Test content".to_string()),
));
let parts = ToPrompt::to_prompt_parts(&expertise);
assert_eq!(parts.len(), 1);
match &parts[0] {
PromptPart::Text(text) => {
assert!(text.contains("Expertise: test"));
assert!(text.contains("Test content"));
}
_ => panic!("Expected Text PromptPart"),
}
}
#[test]
fn test_auto_description() {
let expertise = Expertise::new("test-agent", "1.0");
assert_eq!(expertise.description(), "test-agent");
let expertise_with_desc =
Expertise::new("test-agent", "1.0").with_description("A test agent");
assert_eq!(expertise_with_desc.description(), "A test agent");
let expertise_with_fragment = Expertise::new("test-agent", "1.0")
.with_fragment(WeightedFragment::new(
KnowledgeFragment::Text("You are a helpful assistant specialized in Rust programming. You provide clear, concise, and accurate answers.".to_string()),
));
let auto_desc = expertise_with_fragment.get_description();
assert!(auto_desc.starts_with("You are a helpful assistant"));
assert!(auto_desc.len() <= 103); }
}