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 = "Option::is_none")]
33 pub description: Option<String>,
34
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub tags: Vec<String>,
38
39 pub content: Vec<WeightedFragment>,
41}
42
43impl Expertise {
44 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 pub fn with_description(mut self, description: impl Into<String>) -> Self {
92 self.description = Some(description.into());
93 self
94 }
95
96 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
98 self.tags.push(tag.into());
99 self
100 }
101
102 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
104 self.tags.extend(tags);
105 self
106 }
107
108 pub fn with_fragment(mut self, fragment: WeightedFragment) -> Self {
110 self.content.push(fragment);
111 self
112 }
113
114 pub fn get_description(&self) -> String {
124 if let Some(desc) = &self.description {
125 return desc.clone();
126 }
127
128 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 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 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 format!("{} v{}", self.id, self.version)
165 }
166 }
167
168 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 tool_json
182 .get("name")
183 .and_then(|v| v.as_str())
184 .map(|s| s.to_string())
185 .or_else(|| {
186 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 pub fn to_prompt(&self) -> String {
202 self.to_prompt_with_context(&ContextMatcher::default())
203 }
204
205 pub fn to_prompt_with_render_context(&self, context: &crate::render::RenderContext) -> String {
229 self.to_prompt_with_context(&context.to_context_matcher())
232 }
233
234 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 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 let mut current_priority: Option<Priority> = None;
261 for weighted in sorted_fragments {
262 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 result.push_str(&weighted.fragment.to_prompt());
270 result.push('\n');
271 }
272
273 result
274 }
275
276 pub fn to_mermaid(&self) -> String {
278 let mut result = String::from("graph TD\n");
279
280 result.push_str(&format!(" ROOT[\"Expertise: {}\"]\n", self.id));
282
283 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
428pub struct WeightedFragment {
429 #[serde(default)]
431 pub priority: Priority,
432
433 #[serde(default)]
435 pub context: ContextProfile,
436
437 pub fragment: KnowledgeFragment,
439}
440
441impl WeightedFragment {
442 pub fn new(fragment: KnowledgeFragment) -> Self {
444 Self {
445 priority: Priority::default(),
446 context: ContextProfile::default(),
447 fragment,
448 }
449 }
450
451 pub fn with_priority(mut self, priority: Priority) -> Self {
453 self.priority = priority;
454 self
455 }
456
457 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 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 let prompt1 = expertise.to_prompt_with_context(&ContextMatcher::new());
529 assert!(prompt1.contains("Always visible"));
530 assert!(!prompt1.contains("Debug only"));
531
532 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}