1use crate::context::{ContextMatcher, ContextProfile, Priority};
7use crate::fragment::KnowledgeFragment;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
17pub struct Expertise {
18 pub id: String,
20
21 pub version: String,
23
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub tags: Vec<String>,
27
28 pub content: Vec<WeightedFragment>,
30}
31
32impl Expertise {
33 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 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
45 self.tags.push(tag.into());
46 self
47 }
48
49 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
51 self.tags.extend(tags);
52 self
53 }
54
55 pub fn with_fragment(mut self, fragment: WeightedFragment) -> Self {
57 self.content.push(fragment);
58 self
59 }
60
61 pub fn to_prompt(&self) -> String {
65 self.to_prompt_with_context(&ContextMatcher::default())
66 }
67
68 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 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 let mut current_priority: Option<Priority> = None;
92 for weighted in sorted_fragments {
93 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 result.push_str(&weighted.fragment.to_prompt());
101 result.push('\n');
102 }
103
104 result
105 }
106
107 pub fn to_mermaid(&self) -> String {
109 let mut result = String::from("graph TD\n");
110
111 result.push_str(&format!(" ROOT[\"Expertise: {}\"]\n", self.id));
113
114 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
259pub struct WeightedFragment {
260 #[serde(default)]
262 pub priority: Priority,
263
264 #[serde(default)]
266 pub context: ContextProfile,
267
268 pub fragment: KnowledgeFragment,
270}
271
272impl WeightedFragment {
273 pub fn new(fragment: KnowledgeFragment) -> Self {
275 Self {
276 priority: Priority::default(),
277 context: ContextProfile::default(),
278 fragment,
279 }
280 }
281
282 pub fn with_priority(mut self, priority: Priority) -> Self {
284 self.priority = priority;
285 self
286 }
287
288 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 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 let prompt1 = expertise.to_prompt_with_context(&ContextMatcher::new());
358 assert!(prompt1.contains("Always visible"));
359 assert!(!prompt1.contains("Debug only"));
360
361 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}