Skip to main content

dakera_client/
knowledge.rs

1//! Knowledge graph operations for the Dakera client.
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::Result;
6use crate::types::{KgExportResponse, KgPathResponse, KgQueryResponse};
7use crate::DakeraClient;
8
9// ============================================================================
10// Knowledge Graph Types
11// ============================================================================
12
13/// Request to build a knowledge graph
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct KnowledgeGraphRequest {
16    pub agent_id: String,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub memory_id: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub depth: Option<u32>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub min_similarity: Option<f32>,
23}
24
25/// A node in the knowledge graph
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct KnowledgeNode {
28    pub id: String,
29    pub content: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub memory_type: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub importance: Option<f32>,
34    #[serde(default)]
35    pub metadata: serde_json::Value,
36}
37
38/// An edge in the knowledge graph
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct KnowledgeEdge {
41    pub source: String,
42    pub target: String,
43    pub similarity: f32,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub relationship: Option<String>,
46}
47
48/// Response from knowledge graph operations
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct KnowledgeGraphResponse {
51    pub nodes: Vec<KnowledgeNode>,
52    pub edges: Vec<KnowledgeEdge>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub clusters: Option<Vec<Vec<String>>>,
55}
56
57/// Request to build a full knowledge graph
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct FullKnowledgeGraphRequest {
60    pub agent_id: String,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub max_nodes: Option<u32>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub min_similarity: Option<f32>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub cluster_threshold: Option<f32>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub max_edges_per_node: Option<u32>,
69}
70
71/// Request to summarize memories
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SummarizeRequest {
74    pub agent_id: String,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub memory_ids: Option<Vec<String>>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub target_type: Option<String>,
79    #[serde(default)]
80    pub dry_run: bool,
81}
82
83/// Response from summarization
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct SummarizeResponse {
86    pub summary: String,
87    pub source_count: usize,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub new_memory_id: Option<String>,
90}
91
92/// Request to deduplicate memories
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct DeduplicateRequest {
95    pub agent_id: String,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub threshold: Option<f32>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub memory_type: Option<String>,
100    #[serde(default)]
101    pub dry_run: bool,
102}
103
104/// Response from deduplication
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct DeduplicateResponse {
107    pub duplicates_found: usize,
108    pub removed_count: usize,
109    pub groups: Vec<Vec<String>>,
110}
111
112// ============================================================================
113// Cross-Agent Network Types (DASH-A)
114// ============================================================================
115
116/// Request to build a cross-agent knowledge network
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct CrossAgentNetworkRequest {
119    /// Agent IDs to include; `None` means all agents.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub agent_ids: Option<Vec<String>>,
122    /// Minimum cosine similarity for cross-agent edges (default 0.3).
123    pub min_similarity: f32,
124    /// Maximum memory nodes returned per agent (default 50).
125    pub max_nodes_per_agent: usize,
126    /// Minimum importance score for included nodes (default 0.0).
127    pub min_importance: f32,
128    /// Maximum cross-agent edges in the response (default 200).
129    pub max_cross_edges: usize,
130}
131
132impl Default for CrossAgentNetworkRequest {
133    fn default() -> Self {
134        Self {
135            agent_ids: None,
136            min_similarity: 0.3,
137            max_nodes_per_agent: 50,
138            min_importance: 0.0,
139            max_cross_edges: 200,
140        }
141    }
142}
143
144/// Summary information about a single agent in the network
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct AgentNetworkInfo {
147    pub agent_id: String,
148    pub memory_count: usize,
149    pub avg_importance: f32,
150}
151
152/// A memory node in the cross-agent network graph
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct AgentNetworkNode {
155    pub id: String,
156    pub agent_id: String,
157    pub content: String,
158    pub importance: f32,
159    pub tags: Vec<String>,
160    pub memory_type: String,
161    /// Unix milliseconds.
162    pub created_at: u64,
163}
164
165/// A cross-agent similarity edge between two memory nodes
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct AgentNetworkEdge {
168    pub source: String,
169    pub target: String,
170    pub source_agent: String,
171    pub target_agent: String,
172    pub similarity: f32,
173}
174
175/// Aggregate statistics for the cross-agent network
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct AgentNetworkStats {
178    pub total_agents: usize,
179    pub total_nodes: usize,
180    pub total_cross_edges: usize,
181    pub density: f32,
182}
183
184/// Response from the cross-agent network endpoint
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct CrossAgentNetworkResponse {
187    pub agents: Vec<AgentNetworkInfo>,
188    pub nodes: Vec<AgentNetworkNode>,
189    pub edges: Vec<AgentNetworkEdge>,
190    pub stats: AgentNetworkStats,
191    /// Total number of memory nodes in the network (added in server v0.6.2).
192    #[serde(default)]
193    pub node_count: usize,
194}
195
196// ============================================================================
197// Knowledge Graph Client Methods
198// ============================================================================
199
200impl DakeraClient {
201    /// Build a knowledge graph from a seed memory
202    pub async fn knowledge_graph(
203        &self,
204        request: KnowledgeGraphRequest,
205    ) -> Result<KnowledgeGraphResponse> {
206        let url = format!("{}/v1/knowledge/graph", self.base_url);
207        let response = self.client.post(&url).json(&request).send().await?;
208        self.handle_response(response).await
209    }
210
211    /// Build a full knowledge graph for an agent
212    pub async fn full_knowledge_graph(
213        &self,
214        request: FullKnowledgeGraphRequest,
215    ) -> Result<KnowledgeGraphResponse> {
216        let url = format!("{}/v1/knowledge/graph/full", self.base_url);
217        let response = self.client.post(&url).json(&request).send().await?;
218        self.handle_response(response).await
219    }
220
221    /// Summarize memories
222    pub async fn summarize(&self, request: SummarizeRequest) -> Result<SummarizeResponse> {
223        let url = format!("{}/v1/knowledge/summarize", self.base_url);
224        let response = self.client.post(&url).json(&request).send().await?;
225        self.handle_response(response).await
226    }
227
228    /// Deduplicate memories
229    pub async fn deduplicate(&self, request: DeduplicateRequest) -> Result<DeduplicateResponse> {
230        let url = format!("{}/v1/knowledge/deduplicate", self.base_url);
231        let response = self.client.post(&url).json(&request).send().await?;
232        self.handle_response(response).await
233    }
234
235    /// Build a cross-agent knowledge network (DASH-A).
236    ///
237    /// Calls `POST /v1/knowledge/network/cross-agent` (Admin scope) and returns
238    /// a graph of memory nodes and cross-agent similarity edges.
239    pub async fn cross_agent_network(
240        &self,
241        request: CrossAgentNetworkRequest,
242    ) -> Result<CrossAgentNetworkResponse> {
243        let url = format!("{}/v1/knowledge/network/cross-agent", self.base_url);
244        let response = self.client.post(&url).json(&request).send().await?;
245        self.handle_response(response).await
246    }
247
248    // =========================================================================
249    // KG-2: Graph Query & Export
250    // =========================================================================
251
252    /// Query the memory knowledge graph using a filter DSL (KG-2).
253    ///
254    /// Calls `GET /v1/knowledge/query`.
255    ///
256    /// # Arguments
257    /// - `agent_id` — agent whose graph to query.
258    /// - `root_id` — optional root memory ID for BFS traversal.
259    /// - `edge_type` — comma-separated edge types to filter (e.g. `"related_to,shares_entity"`).
260    /// - `min_weight` — minimum edge weight (0.0–1.0).
261    /// - `max_depth` — BFS depth when `root_id` is set (1–5, default 3).
262    /// - `limit` — maximum edges to return (default 100, max 1000).
263    pub async fn knowledge_query(
264        &self,
265        agent_id: &str,
266        root_id: Option<&str>,
267        edge_type: Option<&str>,
268        min_weight: Option<f32>,
269        max_depth: Option<u32>,
270        limit: Option<usize>,
271    ) -> Result<KgQueryResponse> {
272        let mut url = format!("{}/v1/knowledge/query?agent_id={}", self.base_url, agent_id);
273        if let Some(v) = root_id {
274            url.push_str(&format!("&root_id={}", v));
275        }
276        if let Some(v) = edge_type {
277            url.push_str(&format!("&edge_type={}", v));
278        }
279        if let Some(v) = min_weight {
280            url.push_str(&format!("&min_weight={}", v));
281        }
282        if let Some(v) = max_depth {
283            url.push_str(&format!("&max_depth={}", v));
284        }
285        if let Some(v) = limit {
286            url.push_str(&format!("&limit={}", v));
287        }
288        let response = self.client.get(&url).send().await?;
289        self.handle_response(response).await
290    }
291
292    /// Find the BFS shortest path between two memory IDs (KG-2).
293    ///
294    /// Calls `GET /v1/knowledge/path`.
295    ///
296    /// Returns an error if no path exists between the two memories.
297    pub async fn knowledge_path(
298        &self,
299        agent_id: &str,
300        from_id: &str,
301        to_id: &str,
302    ) -> Result<KgPathResponse> {
303        let url = format!(
304            "{}/v1/knowledge/path?agent_id={}&from={}&to={}",
305            self.base_url, agent_id, from_id, to_id
306        );
307        let response = self.client.get(&url).send().await?;
308        self.handle_response(response).await
309    }
310
311    /// Export the memory knowledge graph as JSON or GraphML (KG-2).
312    ///
313    /// Calls `GET /v1/knowledge/export`.
314    ///
315    /// For `format = "graphml"` the server returns `application/xml`. This
316    /// method deserializes JSON only — use a raw HTTP client for GraphML.
317    pub async fn knowledge_export(
318        &self,
319        agent_id: &str,
320        format: Option<&str>,
321    ) -> Result<KgExportResponse> {
322        let fmt = format.unwrap_or("json");
323        let url = format!(
324            "{}/v1/knowledge/export?agent_id={}&format={}",
325            self.base_url, agent_id, fmt
326        );
327        let response = self.client.get(&url).send().await?;
328        self.handle_response(response).await
329    }
330}
331
332// ============================================================================
333// Tests
334// ============================================================================
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    // -------------------------------------------------------------------------
341    // KnowledgeGraphRequest serialization
342    // -------------------------------------------------------------------------
343
344    #[test]
345    fn test_knowledge_graph_request_minimal_omits_optional() {
346        let req = KnowledgeGraphRequest {
347            agent_id: "agent-1".to_string(),
348            memory_id: None,
349            depth: None,
350            min_similarity: None,
351        };
352        let json = serde_json::to_string(&req).unwrap();
353        assert!(json.contains("\"agent_id\":\"agent-1\""));
354        assert!(!json.contains("memory_id"));
355        assert!(!json.contains("depth"));
356        assert!(!json.contains("min_similarity"));
357    }
358
359    #[test]
360    fn test_knowledge_graph_request_with_all_fields() {
361        let req = KnowledgeGraphRequest {
362            agent_id: "agent-1".to_string(),
363            memory_id: Some("mem-abc".to_string()),
364            depth: Some(3),
365            min_similarity: Some(0.7),
366        };
367        let json = serde_json::to_string(&req).unwrap();
368        assert!(json.contains("\"memory_id\":\"mem-abc\""));
369        assert!(json.contains("\"depth\":3"));
370        assert!(json.contains("\"min_similarity\":0.7"));
371    }
372
373    // -------------------------------------------------------------------------
374    // KnowledgeNode deserialization
375    // -------------------------------------------------------------------------
376
377    #[test]
378    fn test_knowledge_node_deserializes_minimal() {
379        let json = r#"{
380            "id": "n1",
381            "content": "user likes coffee"
382        }"#;
383        let node: KnowledgeNode = serde_json::from_str(json).unwrap();
384        assert_eq!(node.id, "n1");
385        assert!(node.memory_type.is_none());
386        assert!(node.importance.is_none());
387        assert_eq!(node.metadata, serde_json::Value::Null);
388    }
389
390    #[test]
391    fn test_knowledge_node_deserializes_with_optional_fields() {
392        let json = r#"{
393            "id": "n2",
394            "content": "works at Dakera",
395            "memory_type": "semantic",
396            "importance": 0.9
397        }"#;
398        let node: KnowledgeNode = serde_json::from_str(json).unwrap();
399        assert_eq!(node.memory_type.as_deref(), Some("semantic"));
400        assert!((node.importance.unwrap() - 0.9).abs() < 1e-6);
401    }
402
403    // -------------------------------------------------------------------------
404    // KnowledgeEdge serialization
405    // -------------------------------------------------------------------------
406
407    #[test]
408    fn test_knowledge_edge_without_relationship_omits_field() {
409        let edge = KnowledgeEdge {
410            source: "n1".to_string(),
411            target: "n2".to_string(),
412            similarity: 0.85,
413            relationship: None,
414        };
415        let json = serde_json::to_string(&edge).unwrap();
416        assert!(json.contains("\"similarity\":0.85"));
417        assert!(!json.contains("relationship"));
418    }
419
420    #[test]
421    fn test_knowledge_edge_with_relationship() {
422        let edge = KnowledgeEdge {
423            source: "n1".to_string(),
424            target: "n2".to_string(),
425            similarity: 0.92,
426            relationship: Some("colleague".to_string()),
427        };
428        let json = serde_json::to_string(&edge).unwrap();
429        assert!(json.contains("\"relationship\":\"colleague\""));
430    }
431
432    // -------------------------------------------------------------------------
433    // KnowledgeGraphResponse
434    // -------------------------------------------------------------------------
435
436    #[test]
437    fn test_knowledge_graph_response_deserializes_empty() {
438        let json = r#"{"nodes": [], "edges": []}"#;
439        let resp: KnowledgeGraphResponse = serde_json::from_str(json).unwrap();
440        assert!(resp.nodes.is_empty());
441        assert!(resp.edges.is_empty());
442        assert!(resp.clusters.is_none());
443    }
444
445    // -------------------------------------------------------------------------
446    // FullKnowledgeGraphRequest serialization
447    // -------------------------------------------------------------------------
448
449    #[test]
450    fn test_full_knowledge_graph_request_all_optional_omitted() {
451        let req = FullKnowledgeGraphRequest {
452            agent_id: "a".to_string(),
453            max_nodes: None,
454            min_similarity: None,
455            cluster_threshold: None,
456            max_edges_per_node: None,
457        };
458        let json = serde_json::to_string(&req).unwrap();
459        assert!(!json.contains("max_nodes"));
460        assert!(!json.contains("min_similarity"));
461        assert!(!json.contains("cluster_threshold"));
462    }
463
464    // -------------------------------------------------------------------------
465    // SummarizeRequest
466    // -------------------------------------------------------------------------
467
468    #[test]
469    fn test_summarize_request_dry_run_default_false() {
470        let req = SummarizeRequest {
471            agent_id: "a".to_string(),
472            memory_ids: None,
473            target_type: None,
474            dry_run: false,
475        };
476        let json = serde_json::to_string(&req).unwrap();
477        assert!(json.contains("\"dry_run\":false"));
478        assert!(!json.contains("memory_ids"));
479        assert!(!json.contains("target_type"));
480    }
481
482    #[test]
483    fn test_summarize_request_with_memory_ids() {
484        let req = SummarizeRequest {
485            agent_id: "a".to_string(),
486            memory_ids: Some(vec!["m1".to_string(), "m2".to_string()]),
487            target_type: Some("semantic".to_string()),
488            dry_run: true,
489        };
490        let json = serde_json::to_string(&req).unwrap();
491        assert!(json.contains("\"dry_run\":true"));
492        assert!(json.contains("\"m1\""));
493        assert!(json.contains("\"target_type\":\"semantic\""));
494    }
495
496    // -------------------------------------------------------------------------
497    // SummarizeResponse
498    // -------------------------------------------------------------------------
499
500    #[test]
501    fn test_summarize_response_deserializes() {
502        let json = r#"{"summary": "user is a developer", "source_count": 5}"#;
503        let resp: SummarizeResponse = serde_json::from_str(json).unwrap();
504        assert_eq!(resp.summary, "user is a developer");
505        assert_eq!(resp.source_count, 5);
506        assert!(resp.new_memory_id.is_none());
507    }
508
509    // -------------------------------------------------------------------------
510    // DeduplicateRequest
511    // -------------------------------------------------------------------------
512
513    #[test]
514    fn test_deduplicate_request_dry_run_default_false() {
515        let req = DeduplicateRequest {
516            agent_id: "a".to_string(),
517            threshold: None,
518            memory_type: None,
519            dry_run: false,
520        };
521        let json = serde_json::to_string(&req).unwrap();
522        assert!(json.contains("\"dry_run\":false"));
523        assert!(!json.contains("threshold"));
524        assert!(!json.contains("memory_type"));
525    }
526
527    #[test]
528    fn test_deduplicate_request_with_threshold() {
529        let req = DeduplicateRequest {
530            agent_id: "a".to_string(),
531            threshold: Some(0.92),
532            memory_type: Some("episodic".to_string()),
533            dry_run: true,
534        };
535        let json = serde_json::to_string(&req).unwrap();
536        assert!(json.contains("\"threshold\":0.92"));
537        assert!(json.contains("\"memory_type\":\"episodic\""));
538    }
539
540    // -------------------------------------------------------------------------
541    // DeduplicateResponse
542    // -------------------------------------------------------------------------
543
544    #[test]
545    fn test_deduplicate_response_deserializes() {
546        let json =
547            r#"{"duplicates_found": 3, "removed_count": 2, "groups": [["a","b"],["c","d","e"]]}"#;
548        let resp: DeduplicateResponse = serde_json::from_str(json).unwrap();
549        assert_eq!(resp.duplicates_found, 3);
550        assert_eq!(resp.removed_count, 2);
551        assert_eq!(resp.groups.len(), 2);
552    }
553
554    // -------------------------------------------------------------------------
555    // CrossAgentNetworkRequest defaults
556    // -------------------------------------------------------------------------
557
558    #[test]
559    fn test_cross_agent_network_request_default_values() {
560        let req = CrossAgentNetworkRequest::default();
561        assert!(req.agent_ids.is_none());
562        assert!((req.min_similarity - 0.3).abs() < 1e-6);
563        assert_eq!(req.max_nodes_per_agent, 50);
564        assert!((req.min_importance - 0.0).abs() < 1e-6);
565        assert_eq!(req.max_cross_edges, 200);
566    }
567
568    // -------------------------------------------------------------------------
569    // CrossAgentNetworkResponse
570    // -------------------------------------------------------------------------
571
572    #[test]
573    fn test_cross_agent_network_response_node_count_defaults_zero() {
574        let json = r#"{
575            "agents": [],
576            "nodes": [],
577            "edges": [],
578            "stats": {
579                "total_agents": 0,
580                "total_nodes": 0,
581                "total_cross_edges": 0,
582                "density": 0.0
583            }
584        }"#;
585        let resp: CrossAgentNetworkResponse = serde_json::from_str(json).unwrap();
586        assert_eq!(resp.node_count, 0);
587        assert!(resp.agents.is_empty());
588    }
589}