1use async_trait::async_trait;
7use serde_json::json;
8use std::sync::Arc;
9use zeroclaw_api::tool::{Tool, ToolResult};
10use zeroclaw_memory::knowledge_graph::{KnowledgeGraph, NodeType, Relation};
11
12pub struct KnowledgeTool {
14 graph: Arc<KnowledgeGraph>,
15}
16
17impl KnowledgeTool {
18 pub fn new(graph: Arc<KnowledgeGraph>) -> Self {
19 Self { graph }
20 }
21}
22
23#[async_trait]
24impl Tool for KnowledgeTool {
25 fn name(&self) -> &str {
26 "knowledge"
27 }
28
29 fn description(&self) -> &str {
30 "Manage a knowledge graph of architecture decisions, solution patterns, lessons learned, and experts. Actions: capture, search, relate, suggest, expert_find, lessons_extract, graph_stats."
31 }
32
33 fn parameters_schema(&self) -> serde_json::Value {
34 json!({
35 "type": "object",
36 "properties": {
37 "action": {
38 "type": "string",
39 "enum": ["capture", "search", "relate", "suggest", "expert_find", "lessons_extract", "graph_stats"],
40 "description": "The action to perform"
41 },
42 "node_type": {
43 "type": "string",
44 "enum": ["pattern", "decision", "lesson", "expert", "technology"],
45 "description": "Type of knowledge node (for capture)"
46 },
47 "title": {
48 "type": "string",
49 "description": "Title for the knowledge item (for capture)"
50 },
51 "content": {
52 "type": "string",
53 "description": "Content body (for capture) or text to extract lessons from (for lessons_extract)"
54 },
55 "tags": {
56 "type": "array",
57 "items": { "type": "string" },
58 "description": "Tags for filtering and categorization"
59 },
60 "source_project": {
61 "type": "string",
62 "description": "Source project identifier (for capture)"
63 },
64 "query": {
65 "type": "string",
66 "description": "Search query text (for search, suggest)"
67 },
68 "from_id": {
69 "type": "string",
70 "description": "Source node ID (for relate)"
71 },
72 "to_id": {
73 "type": "string",
74 "description": "Target node ID (for relate)"
75 },
76 "relation": {
77 "type": "string",
78 "enum": ["uses", "replaces", "extends", "authored_by", "applies_to"],
79 "description": "Relationship type (for relate)"
80 },
81 "filters": {
82 "type": "object",
83 "properties": {
84 "node_type": { "type": "string" },
85 "tags": { "type": "array", "items": { "type": "string" } },
86 "project": { "type": "string" }
87 },
88 "description": "Optional search filters"
89 }
90 },
91 "required": ["action"]
92 })
93 }
94
95 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
96 let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
97 ::zeroclaw_log::record!(
98 WARN,
99 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
100 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
101 .with_attrs(::serde_json::json!({"param": "action"})),
102 "knowledge_tool: missing action parameter"
103 );
104 anyhow::Error::msg("missing 'action' parameter")
105 })?;
106
107 match action {
108 "capture" => self.handle_capture(&args),
109 "search" => self.handle_search(&args),
110 "relate" => self.handle_relate(&args),
111 "suggest" => self.handle_suggest(&args),
112 "expert_find" => self.handle_expert_find(&args),
113 "lessons_extract" => self.handle_lessons_extract(&args),
114 "graph_stats" => self.handle_graph_stats(),
115 other => Ok(ToolResult {
116 success: false,
117 output: String::new(),
118 error: Some(format!("unknown action: {other}")),
119 }),
120 }
121 }
122}
123
124impl KnowledgeTool {
125 fn handle_capture(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
126 let node_type_str = args
127 .get("node_type")
128 .and_then(|v| v.as_str())
129 .ok_or_else(|| {
130 ::zeroclaw_log::record!(
131 WARN,
132 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
133 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
134 .with_attrs(::serde_json::json!({
135 "action": "capture",
136 "param": "node_type",
137 })),
138 "knowledge_tool: capture missing node_type"
139 );
140 anyhow::Error::msg("missing 'node_type' for capture")
141 })?;
142 let title = args.get("title").and_then(|v| v.as_str()).ok_or_else(|| {
143 ::zeroclaw_log::record!(
144 WARN,
145 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
146 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
147 .with_attrs(::serde_json::json!({
148 "action": "capture",
149 "param": "title",
150 })),
151 "knowledge_tool: capture missing title"
152 );
153 anyhow::Error::msg("missing 'title' for capture")
154 })?;
155 let content = args
156 .get("content")
157 .and_then(|v| v.as_str())
158 .ok_or_else(|| {
159 ::zeroclaw_log::record!(
160 WARN,
161 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
162 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
163 .with_attrs(::serde_json::json!({
164 "action": "capture",
165 "param": "content",
166 })),
167 "knowledge_tool: capture missing content"
168 );
169 anyhow::Error::msg("missing 'content' for capture")
170 })?;
171
172 let node_type = NodeType::parse(node_type_str).map_err(|e| {
173 ::zeroclaw_log::record!(
174 WARN,
175 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
176 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
177 .with_attrs(::serde_json::json!({
178 "node_type": node_type_str,
179 "error": format!("{}", e),
180 })),
181 "knowledge_tool: invalid node_type"
182 );
183 anyhow::Error::msg(format!("{e}"))
184 })?;
185
186 let tags: Vec<String> = args
187 .get("tags")
188 .and_then(|v| v.as_array())
189 .map(|arr| {
190 arr.iter()
191 .filter_map(|v| v.as_str().map(String::from))
192 .collect()
193 })
194 .unwrap_or_default();
195
196 let source_project = args.get("source_project").and_then(|v| v.as_str());
197
198 match self
199 .graph
200 .add_node(node_type, title, content, &tags, source_project)
201 {
202 Ok(id) => Ok(ToolResult {
203 success: true,
204 output: json!({ "node_id": id }).to_string(),
205 error: None,
206 }),
207 Err(e) => Ok(ToolResult {
208 success: false,
209 output: String::new(),
210 error: Some(format!("capture failed: {e}")),
211 }),
212 }
213 }
214
215 fn handle_search(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
216 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
217
218 let filter_tags: Vec<String> = args
220 .get("filters")
221 .and_then(|f| f.get("tags"))
222 .and_then(|v| v.as_array())
223 .map(|arr| {
224 arr.iter()
225 .filter_map(|v| v.as_str().map(String::from))
226 .collect()
227 })
228 .unwrap_or_default();
229
230 let filter_type = args
231 .get("filters")
232 .and_then(|f| f.get("node_type"))
233 .and_then(|v| v.as_str());
234
235 let filter_project = args
236 .get("filters")
237 .and_then(|f| f.get("project"))
238 .and_then(|v| v.as_str());
239
240 let parsed_filter_type = filter_type.and_then(|ft| NodeType::parse(ft).ok());
242
243 let results = if query.is_empty() && !filter_tags.is_empty() {
244 let mut nodes = self.graph.query_by_tags(&filter_tags)?;
246 if let Some(ref nt) = parsed_filter_type {
247 nodes.retain(|n| &n.node_type == nt);
248 }
249 if let Some(proj) = filter_project {
250 nodes.retain(|n| n.source_project.as_deref() == Some(proj));
251 }
252 nodes
253 .into_iter()
254 .map(|node| json!({ "id": node.id, "type": node.node_type, "title": node.title, "score": 1.0 }))
255 .collect::<Vec<_>>()
256 } else if !query.is_empty() {
257 let mut search_results = self.graph.query_by_similarity(query, 20)?;
258
259 if let Some(ref nt) = parsed_filter_type {
261 search_results.retain(|r| &r.node.node_type == nt);
262 }
263 if let Some(proj) = filter_project {
265 search_results.retain(|r| r.node.source_project.as_deref() == Some(proj));
266 }
267 if !filter_tags.is_empty() {
269 search_results.retain(|r| filter_tags.iter().all(|t| r.node.tags.contains(t)));
270 }
271
272 search_results
273 .into_iter()
274 .map(|r| {
275 json!({
276 "id": r.node.id,
277 "type": r.node.node_type,
278 "title": r.node.title,
279 "score": r.score
280 })
281 })
282 .collect::<Vec<_>>()
283 } else {
284 Vec::new()
285 };
286
287 Ok(ToolResult {
288 success: true,
289 output: json!({ "results": results, "count": results.len() }).to_string(),
290 error: None,
291 })
292 }
293
294 fn handle_relate(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
295 let from_id = args
296 .get("from_id")
297 .and_then(|v| v.as_str())
298 .ok_or_else(|| {
299 ::zeroclaw_log::record!(
300 WARN,
301 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
302 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
303 .with_attrs(::serde_json::json!({
304 "action": "relate",
305 "param": "from_id",
306 })),
307 "knowledge_tool: relate missing from_id"
308 );
309 anyhow::Error::msg("missing 'from_id' for relate")
310 })?;
311 let to_id = args.get("to_id").and_then(|v| v.as_str()).ok_or_else(|| {
312 ::zeroclaw_log::record!(
313 WARN,
314 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
315 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
316 .with_attrs(::serde_json::json!({
317 "action": "relate",
318 "param": "to_id",
319 })),
320 "knowledge_tool: relate missing to_id"
321 );
322 anyhow::Error::msg("missing 'to_id' for relate")
323 })?;
324 let relation_str = args
325 .get("relation")
326 .and_then(|v| v.as_str())
327 .ok_or_else(|| {
328 ::zeroclaw_log::record!(
329 WARN,
330 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
331 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
332 .with_attrs(::serde_json::json!({
333 "action": "relate",
334 "param": "relation",
335 })),
336 "knowledge_tool: relate missing relation"
337 );
338 anyhow::Error::msg("missing 'relation' for relate")
339 })?;
340
341 let relation = Relation::parse(relation_str).map_err(|e| {
342 ::zeroclaw_log::record!(
343 WARN,
344 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
345 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
346 .with_attrs(::serde_json::json!({
347 "relation": relation_str,
348 "error": format!("{}", e),
349 })),
350 "knowledge_tool: invalid relation"
351 );
352 anyhow::Error::msg(format!("{e}"))
353 })?;
354
355 match self.graph.add_edge(from_id, to_id, relation) {
356 Ok(()) => Ok(ToolResult {
357 success: true,
358 output: "relationship created".to_string(),
359 error: None,
360 }),
361 Err(e) => Ok(ToolResult {
362 success: false,
363 output: String::new(),
364 error: Some(format!("relate failed: {e}")),
365 }),
366 }
367 }
368
369 fn handle_suggest(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
370 let query = args
371 .get("query")
372 .or_else(|| args.get("content"))
373 .and_then(|v| v.as_str())
374 .ok_or_else(|| {
375 ::zeroclaw_log::record!(
376 WARN,
377 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
378 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
379 .with_attrs(::serde_json::json!({
380 "action": "suggest",
381 "missing": "query_or_content",
382 })),
383 "knowledge_tool: suggest missing query/content"
384 );
385 anyhow::Error::msg("missing 'query' or 'content' for suggest")
386 })?;
387
388 let results = self.graph.query_by_similarity(query, 10)?;
389 let suggestions: Vec<serde_json::Value> = results
390 .into_iter()
391 .map(|r| {
392 json!({
393 "id": r.node.id,
394 "type": r.node.node_type,
395 "title": r.node.title,
396 "content_preview": truncate_str(&r.node.content, 200),
397 "tags": r.node.tags,
398 "relevance_score": r.score,
399 })
400 })
401 .collect();
402
403 Ok(ToolResult {
404 success: true,
405 output: json!({ "suggestions": suggestions, "count": suggestions.len() }).to_string(),
406 error: None,
407 })
408 }
409
410 fn handle_expert_find(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
411 let tags: Vec<String> = args
412 .get("tags")
413 .and_then(|v| v.as_array())
414 .map(|arr| {
415 arr.iter()
416 .filter_map(|v| v.as_str().map(String::from))
417 .collect()
418 })
419 .unwrap_or_default();
420
421 if tags.is_empty() {
422 return Ok(ToolResult {
423 success: false,
424 output: String::new(),
425 error: Some("missing 'tags' for expert_find".into()),
426 });
427 }
428
429 let experts = self.graph.find_experts(&tags)?;
430 let output: Vec<serde_json::Value> = experts
431 .into_iter()
432 .map(|r| {
433 json!({
434 "id": r.node.id,
435 "name": r.node.title,
436 "contribution_score": r.score,
437 "tags": r.node.tags,
438 })
439 })
440 .collect();
441
442 Ok(ToolResult {
443 success: true,
444 output: json!({ "experts": output, "count": output.len() }).to_string(),
445 error: None,
446 })
447 }
448
449 fn handle_lessons_extract(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
450 let text = args
451 .get("content")
452 .and_then(|v| v.as_str())
453 .ok_or_else(|| {
454 ::zeroclaw_log::record!(
455 WARN,
456 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
457 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
458 .with_attrs(::serde_json::json!({
459 "action": "lessons_extract",
460 "param": "content",
461 })),
462 "knowledge_tool: lessons_extract missing content"
463 );
464 anyhow::Error::msg("missing 'content' for lessons_extract")
465 })?;
466
467 let signal_words = [
470 "learned",
471 "lesson",
472 "mistake",
473 "should have",
474 "next time",
475 "improvement",
476 "better",
477 "avoid",
478 "risk",
479 "issue",
480 "root cause",
481 "takeaway",
482 "insight",
483 "recommendation",
484 "decision",
485 ];
486
487 let sentences: Vec<&str> = text
488 .split(&['.', '!', '?', '\n'][..])
489 .map(str::trim)
490 .filter(|s| s.len() > 10)
491 .collect();
492
493 let mut lessons: Vec<serde_json::Value> = Vec::new();
494 for sentence in &sentences {
495 let lower = sentence.to_ascii_lowercase();
496 let score: f64 = signal_words.iter().filter(|w| lower.contains(**w)).count() as f64;
497 if score > 0.0 {
498 lessons.push(json!({
499 "text": sentence,
500 "confidence": (score / signal_words.len() as f64).min(1.0),
501 }));
502 }
503 }
504
505 lessons.sort_by(|a, b| {
506 let sa = a["confidence"].as_f64().unwrap_or(0.0);
507 let sb = b["confidence"].as_f64().unwrap_or(0.0);
508 sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
509 });
510 lessons.truncate(10);
511
512 Ok(ToolResult {
513 success: true,
514 output: json!({ "lessons": lessons, "count": lessons.len() }).to_string(),
515 error: None,
516 })
517 }
518
519 fn handle_graph_stats(&self) -> anyhow::Result<ToolResult> {
520 match self.graph.stats() {
521 Ok(stats) => Ok(ToolResult {
522 success: true,
523 output: serde_json::to_string(&stats).unwrap_or_default(),
524 error: None,
525 }),
526 Err(e) => Ok(ToolResult {
527 success: false,
528 output: String::new(),
529 error: Some(format!("failed to get stats: {e}")),
530 }),
531 }
532 }
533}
534
535fn truncate_str(s: &str, max_len: usize) -> String {
536 if s.chars().count() <= max_len {
537 s.to_string()
538 } else {
539 let truncated: String = s.chars().take(max_len).collect();
540 format!("{truncated}...")
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use tempfile::TempDir;
548 use zeroclaw_memory::knowledge_graph::KnowledgeGraph;
549
550 fn test_tool() -> (TempDir, KnowledgeTool) {
551 let tmp = TempDir::new().unwrap();
552 let db_path = tmp.path().join("knowledge.db");
553 let graph = Arc::new(KnowledgeGraph::new(&db_path, 10000).unwrap());
554 (tmp, KnowledgeTool::new(graph))
555 }
556
557 #[tokio::test]
558 async fn capture_returns_node_id() {
559 let (_tmp, tool) = test_tool();
560 let result = tool
561 .execute(json!({
562 "action": "capture",
563 "node_type": "pattern",
564 "title": "Circuit Breaker",
565 "content": "Use circuit breaker for external calls",
566 "tags": ["resilience", "microservices"]
567 }))
568 .await
569 .unwrap();
570
571 assert!(result.success);
572 let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
573 assert!(output["node_id"].is_string());
574 }
575
576 #[tokio::test]
577 async fn search_returns_results() {
578 let (_tmp, tool) = test_tool();
579 tool.execute(json!({
580 "action": "capture",
581 "node_type": "decision",
582 "title": "Use Kubernetes",
583 "content": "Kubernetes for container orchestration",
584 "tags": ["infrastructure"]
585 }))
586 .await
587 .unwrap();
588
589 let result = tool
590 .execute(json!({
591 "action": "search",
592 "query": "Kubernetes container"
593 }))
594 .await
595 .unwrap();
596
597 assert!(result.success);
598 let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
599 assert!(output["count"].as_u64().unwrap() > 0);
600 }
601
602 #[tokio::test]
603 async fn relate_creates_edge() {
604 let (_tmp, tool) = test_tool();
605
606 let r1 = tool
607 .execute(json!({
608 "action": "capture",
609 "node_type": "pattern",
610 "title": "CQRS",
611 "content": "Command Query Responsibility Segregation"
612 }))
613 .await
614 .unwrap();
615 let id1: serde_json::Value = serde_json::from_str(&r1.output).unwrap();
616
617 let r2 = tool
618 .execute(json!({
619 "action": "capture",
620 "node_type": "technology",
621 "title": "Event Sourcing",
622 "content": "Event sourcing pattern"
623 }))
624 .await
625 .unwrap();
626 let id2: serde_json::Value = serde_json::from_str(&r2.output).unwrap();
627
628 let result = tool
629 .execute(json!({
630 "action": "relate",
631 "from_id": id1["node_id"],
632 "to_id": id2["node_id"],
633 "relation": "uses"
634 }))
635 .await
636 .unwrap();
637
638 assert!(result.success);
639 }
640
641 #[tokio::test]
642 async fn graph_stats_reports_counts() {
643 let (_tmp, tool) = test_tool();
644 tool.execute(json!({
645 "action": "capture",
646 "node_type": "lesson",
647 "title": "Test lesson",
648 "content": "Testing matters"
649 }))
650 .await
651 .unwrap();
652
653 let result = tool
654 .execute(json!({ "action": "graph_stats" }))
655 .await
656 .unwrap();
657
658 assert!(result.success);
659 let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
660 assert_eq!(output["total_nodes"].as_u64().unwrap(), 1);
661 }
662
663 #[tokio::test]
664 async fn lessons_extract_finds_signal_sentences() {
665 let (_tmp, tool) = test_tool();
666 let result = tool
667 .execute(json!({
668 "action": "lessons_extract",
669 "content": "The project went well overall. We learned that caching is critical. Next time we should avoid tight coupling. The weather was nice."
670 }))
671 .await
672 .unwrap();
673
674 assert!(result.success);
675 let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
676 assert!(output["count"].as_u64().unwrap() >= 1);
677 }
678
679 #[tokio::test]
680 async fn unknown_action_returns_error() {
681 let (_tmp, tool) = test_tool();
682 let result = tool
683 .execute(json!({ "action": "delete_all" }))
684 .await
685 .unwrap();
686 assert!(!result.success);
687 assert!(result.error.unwrap().contains("unknown action"));
688 }
689
690 #[test]
691 fn name_and_schema_are_valid() {
692 let tmp = TempDir::new().unwrap();
693 let db_path = tmp.path().join("knowledge.db");
694 let graph = Arc::new(KnowledgeGraph::new(&db_path, 100).unwrap());
695 let tool = KnowledgeTool::new(graph);
696
697 assert_eq!(tool.name(), "knowledge");
698 let schema = tool.parameters_schema();
699 assert!(schema["properties"]["action"].is_object());
700 }
701}