1use super::truth::{BehavioralTruth, TruthCategory, TruthFeedback, TruthSource};
7use anyhow::{Context, Result};
8use reqwest::Client;
9use serde::{Deserialize, Serialize};
10
11pub struct KnowledgeApiClient {
13 client: Client,
15
16 base_url: String,
18
19 auth_token: Option<String>,
21}
22
23impl KnowledgeApiClient {
24 pub fn new(base_url: &str, auth_token: Option<String>) -> Self {
26 Self {
27 client: Client::new(),
28 base_url: base_url.trim_end_matches('/').to_string(),
29 auth_token,
30 }
31 }
32
33 fn auth_header(&self) -> Option<String> {
35 self.auth_token.as_ref().map(|t| format!("Bearer {}", t))
36 }
37
38 pub async fn sync(&self, request: SyncRequest) -> Result<SyncResponse> {
40 let url = format!("{}/api/knowledge/sync", self.base_url);
41
42 let mut req = self.client.post(&url).json(&request);
43 if let Some(auth) = self.auth_header() {
44 req = req.header("Authorization", auth);
45 }
46
47 let response = req.send().await.context("Failed to send sync request")?;
48
49 if response.status().is_success() {
50 let sync_response: SyncResponse = response
51 .json()
52 .await
53 .context("Failed to parse sync response")?;
54 Ok(sync_response)
55 } else {
56 let status = response.status();
57 let error_text = response.text().await.unwrap_or_default();
58 anyhow::bail!("Sync failed with status {}: {}", status, error_text);
59 }
60 }
61
62 pub async fn get_truths(&self, params: GetTruthsParams) -> Result<GetTruthsResponse> {
64 let mut url = format!("{}/api/knowledge/truths", self.base_url);
65
66 let mut query_parts = Vec::new();
67 if let Some(cat) = ¶ms.category {
68 query_parts.push(format!("category={}", cat));
69 }
70 if let Some(q) = ¶ms.query {
71 query_parts.push(format!("query={}", urlencoding::encode(q)));
72 }
73 if let Some(min) = params.min_confidence {
74 query_parts.push(format!("min_confidence={}", min));
75 }
76 if let Some(lim) = params.limit {
77 query_parts.push(format!("limit={}", lim));
78 }
79 if params.stats {
80 query_parts.push("stats=true".to_string());
81 }
82
83 if !query_parts.is_empty() {
84 url = format!("{}?{}", url, query_parts.join("&"));
85 }
86
87 let mut req = self.client.get(&url);
88 if let Some(auth) = self.auth_header() {
89 req = req.header("Authorization", auth);
90 }
91
92 let response = req
93 .send()
94 .await
95 .context("Failed to send get truths request")?;
96
97 if response.status().is_success() {
98 let truths_response: GetTruthsResponse = response
99 .json()
100 .await
101 .context("Failed to parse truths response")?;
102 Ok(truths_response)
103 } else {
104 let status = response.status();
105 let error_text = response.text().await.unwrap_or_default();
106 anyhow::bail!("Get truths failed with status {}: {}", status, error_text);
107 }
108 }
109
110 pub async fn submit_truth(&self, truth: &TruthSubmission) -> Result<SubmitResponse> {
112 let url = format!("{}/api/knowledge/truths", self.base_url);
113
114 let mut req = self.client.post(&url).json(truth);
115 if let Some(auth) = self.auth_header() {
116 req = req.header("Authorization", auth);
117 }
118
119 let response = req.send().await.context("Failed to send submit request")?;
120
121 if response.status().is_success() {
122 let submit_response: SubmitResponse = response
123 .json()
124 .await
125 .context("Failed to parse submit response")?;
126 Ok(submit_response)
127 } else {
128 let status = response.status();
129 let error_text = response.text().await.unwrap_or_default();
130 anyhow::bail!("Submit failed with status {}: {}", status, error_text);
131 }
132 }
133
134 pub async fn reinforce(
136 &self,
137 truth_id: &str,
138 context: Option<&str>,
139 ) -> Result<ReinforcementResponse> {
140 let url = format!(
141 "{}/api/knowledge/truths/{}/reinforce",
142 self.base_url, truth_id
143 );
144
145 let body = ReinforcementRequest {
146 context: context.map(|s| s.to_string()),
147 ema_alpha: None,
148 };
149
150 let mut req = self.client.post(&url).json(&body);
151 if let Some(auth) = self.auth_header() {
152 req = req.header("Authorization", auth);
153 }
154
155 let response = req
156 .send()
157 .await
158 .context("Failed to send reinforce request")?;
159
160 if response.status().is_success() {
161 let resp: ReinforcementResponse = response
162 .json()
163 .await
164 .context("Failed to parse reinforce response")?;
165 Ok(resp)
166 } else {
167 let status = response.status();
168 let error_text = response.text().await.unwrap_or_default();
169 anyhow::bail!("Reinforce failed with status {}: {}", status, error_text);
170 }
171 }
172
173 pub async fn contradict(
175 &self,
176 truth_id: &str,
177 reason: Option<&str>,
178 context: Option<&str>,
179 ) -> Result<ContradictionResponse> {
180 let url = format!(
181 "{}/api/knowledge/truths/{}/contradict",
182 self.base_url, truth_id
183 );
184
185 let body = ContradictionRequest {
186 context: context.map(|s| s.to_string()),
187 reason: reason.map(|s| s.to_string()),
188 ema_alpha: None,
189 };
190
191 let mut req = self.client.post(&url).json(&body);
192 if let Some(auth) = self.auth_header() {
193 req = req.header("Authorization", auth);
194 }
195
196 let response = req
197 .send()
198 .await
199 .context("Failed to send contradict request")?;
200
201 if response.status().is_success() {
202 let resp: ContradictionResponse = response
203 .json()
204 .await
205 .context("Failed to parse contradict response")?;
206 Ok(resp)
207 } else {
208 let status = response.status();
209 let error_text = response.text().await.unwrap_or_default();
210 anyhow::bail!("Contradict failed with status {}: {}", status, error_text);
211 }
212 }
213
214 pub async fn health_check(&self) -> Result<bool> {
216 let url = format!("{}/api/health", self.base_url);
217
218 match self.client.get(&url).send().await {
219 Ok(response) => Ok(response.status().is_success()),
220 Err(_) => Ok(false),
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, Default)]
229pub struct SyncRequest {
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub since: Option<String>,
233
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub client_id: Option<String>,
237
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub min_confidence: Option<f32>,
241
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub limit: Option<u32>,
245
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub truths: Option<Vec<TruthSubmission>>,
249
250 #[serde(skip_serializing_if = "Option::is_none")]
252 pub feedback: Option<Vec<TruthFeedback>>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct SyncResponse {
258 pub truths: Vec<ServerTruth>,
260
261 pub sync_timestamp: String,
263
264 pub has_more: bool,
266
267 #[serde(default)]
269 pub stats: SyncStats,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, Default)]
274pub struct SyncStats {
275 pub truths_received: u32,
277 pub truths_sent: u32,
279 pub feedback_sent: u32,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct ServerTruth {
286 pub id: String,
288 pub category: String,
290 pub context_pattern: String,
292 pub rule: String,
294 pub rationale: String,
296 pub source: String,
298 pub confidence: f32,
300 pub reinforcements: i32,
302 pub contradictions: i32,
304 pub created_by: Option<String>,
306 pub deleted: bool,
308 pub version: i32,
310 pub created_at: String,
312 pub updated_at: String,
314 pub last_used: String,
316}
317
318impl ServerTruth {
319 pub fn to_behavioral_truth(&self) -> BehavioralTruth {
321 let category = match self.category.as_str() {
322 "command_usage" => TruthCategory::CommandUsage,
323 "task_strategy" => TruthCategory::TaskStrategy,
324 "tool_behavior" => TruthCategory::ToolBehavior,
325 "error_recovery" => TruthCategory::ErrorRecovery,
326 "resource_management" => TruthCategory::ResourceManagement,
327 "pattern_avoidance" => TruthCategory::PatternAvoidance,
328 _ => TruthCategory::CommandUsage,
329 };
330
331 let source = match self.source.as_str() {
332 "explicit_command" => TruthSource::ExplicitCommand,
333 "conversation_correction" => TruthSource::ConversationCorrection,
334 "success_pattern" => TruthSource::SuccessPattern,
335 "failure_pattern" => TruthSource::FailurePattern,
336 _ => TruthSource::ExplicitCommand,
337 };
338
339 let created_at = chrono::DateTime::parse_from_rfc3339(&self.created_at)
341 .map(|dt| dt.timestamp())
342 .unwrap_or_else(|_| chrono::Utc::now().timestamp());
343
344 let last_used = chrono::DateTime::parse_from_rfc3339(&self.last_used)
345 .map(|dt| dt.timestamp())
346 .unwrap_or_else(|_| chrono::Utc::now().timestamp());
347
348 BehavioralTruth {
349 id: self.id.clone(),
350 category,
351 context_pattern: self.context_pattern.clone(),
352 rule: self.rule.clone(),
353 rationale: self.rationale.clone(),
354 source,
355 confidence: self.confidence,
356 reinforcements: self.reinforcements as u32,
357 contradictions: self.contradictions as u32,
358 created_at,
359 last_used,
360 created_by: self.created_by.clone(),
361 version: self.version as u64,
362 deleted: self.deleted,
363 }
364 }
365}
366
367#[derive(Debug, Clone, Default)]
369pub struct GetTruthsParams {
370 pub category: Option<String>,
372 pub query: Option<String>,
374 pub min_confidence: Option<f32>,
376 pub limit: Option<u32>,
378 pub stats: bool,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct GetTruthsResponse {
385 #[serde(default)]
387 pub truths: Vec<ServerTruth>,
388
389 #[serde(default)]
391 pub total_truths: Option<u32>,
392 #[serde(default)]
394 pub by_category: Option<std::collections::HashMap<String, u32>>,
395 #[serde(default)]
397 pub avg_confidence: Option<f32>,
398 #[serde(default)]
400 pub total_reinforcements: Option<u32>,
401 #[serde(default)]
403 pub total_contradictions: Option<u32>,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct TruthSubmission {
409 pub category: String,
411 pub context_pattern: String,
413 pub rule: String,
415 pub rationale: String,
417 pub source: String,
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub confidence: Option<f32>,
422}
423
424impl From<&BehavioralTruth> for TruthSubmission {
425 fn from(truth: &BehavioralTruth) -> Self {
426 Self {
427 category: truth.category.to_snake_case(),
428 context_pattern: truth.context_pattern.clone(),
429 rule: truth.rule.clone(),
430 rationale: truth.rationale.clone(),
431 source: truth.source.to_snake_case(),
432 confidence: Some(truth.confidence),
433 }
434 }
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct SubmitResponse {
440 pub truth: ServerTruth,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize)]
446struct ReinforcementRequest {
447 #[serde(skip_serializing_if = "Option::is_none")]
448 context: Option<String>,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 ema_alpha: Option<f32>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct ReinforcementResponse {
456 pub truth: Option<ServerTruth>,
458 pub message: String,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
464struct ContradictionRequest {
465 #[serde(skip_serializing_if = "Option::is_none")]
466 context: Option<String>,
467 #[serde(skip_serializing_if = "Option::is_none")]
468 reason: Option<String>,
469 #[serde(skip_serializing_if = "Option::is_none")]
470 ema_alpha: Option<f32>,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct ContradictionResponse {
476 pub truth: Option<ServerTruth>,
478 pub message: String,
480 #[serde(default)]
482 pub was_deleted: bool,
483}
484
485trait ToSnakeCase {
488 fn to_snake_case(&self) -> String;
489}
490
491impl ToSnakeCase for TruthCategory {
492 fn to_snake_case(&self) -> String {
493 match self {
494 TruthCategory::CommandUsage => "command_usage",
495 TruthCategory::TaskStrategy => "task_strategy",
496 TruthCategory::ToolBehavior => "tool_behavior",
497 TruthCategory::ErrorRecovery => "error_recovery",
498 TruthCategory::ResourceManagement => "resource_management",
499 TruthCategory::PatternAvoidance => "pattern_avoidance",
500 TruthCategory::PromptingTechnique => "prompting_technique",
501 TruthCategory::ClarifyingQuestions => "clarifying_questions",
502 }
503 .to_string()
504 }
505}
506
507impl ToSnakeCase for TruthSource {
508 fn to_snake_case(&self) -> String {
509 match self {
510 TruthSource::ExplicitCommand => "explicit_command",
511 TruthSource::ConversationCorrection => "conversation_correction",
512 TruthSource::SuccessPattern => "success_pattern",
513 TruthSource::FailurePattern => "failure_pattern",
514 }
515 .to_string()
516 }
517}
518
519#[cfg(test)]
522#[allow(missing_docs)]
523pub struct MockKnowledgeApiClient {
525 pub truths: Vec<BehavioralTruth>,
526 pub submitted: Vec<BehavioralTruth>,
527 pub reinforced: Vec<String>,
528 pub contradicted: Vec<String>,
529}
530
531#[cfg(test)]
532#[allow(missing_docs)]
533impl MockKnowledgeApiClient {
534 pub fn new() -> Self {
535 Self {
536 truths: Vec::new(),
537 submitted: Vec::new(),
538 reinforced: Vec::new(),
539 contradicted: Vec::new(),
540 }
541 }
542
543 pub fn with_truths(truths: Vec<BehavioralTruth>) -> Self {
544 Self {
545 truths,
546 submitted: Vec::new(),
547 reinforced: Vec::new(),
548 contradicted: Vec::new(),
549 }
550 }
551
552 pub async fn sync(&self, _request: SyncRequest) -> Result<SyncResponse> {
553 use chrono::{TimeZone, Utc};
554 Ok(SyncResponse {
555 truths: self
556 .truths
557 .iter()
558 .map(|t| {
559 let created = Utc.timestamp_opt(t.created_at, 0).unwrap();
560 let used = Utc.timestamp_opt(t.last_used, 0).unwrap();
561 ServerTruth {
562 id: t.id.clone(),
563 category: t.category.to_snake_case(),
564 context_pattern: t.context_pattern.clone(),
565 rule: t.rule.clone(),
566 rationale: t.rationale.clone(),
567 source: t.source.to_snake_case(),
568 confidence: t.confidence,
569 reinforcements: t.reinforcements as i32,
570 contradictions: t.contradictions as i32,
571 created_by: t.created_by.clone(),
572 deleted: t.deleted,
573 version: t.version as i32,
574 created_at: created.to_rfc3339(),
575 updated_at: created.to_rfc3339(),
576 last_used: used.to_rfc3339(),
577 }
578 })
579 .collect(),
580 sync_timestamp: Utc::now().to_rfc3339(),
581 has_more: false,
582 stats: SyncStats::default(),
583 })
584 }
585
586 pub async fn submit_truth(&mut self, truth: &BehavioralTruth) -> Result<SubmitResponse> {
587 use chrono::{TimeZone, Utc};
588 self.submitted.push(truth.clone());
589 let created = Utc.timestamp_opt(truth.created_at, 0).unwrap();
590 let used = Utc.timestamp_opt(truth.last_used, 0).unwrap();
591 Ok(SubmitResponse {
592 truth: ServerTruth {
593 id: truth.id.clone(),
594 category: truth.category.to_snake_case(),
595 context_pattern: truth.context_pattern.clone(),
596 rule: truth.rule.clone(),
597 rationale: truth.rationale.clone(),
598 source: truth.source.to_snake_case(),
599 confidence: truth.confidence,
600 reinforcements: truth.reinforcements as i32,
601 contradictions: truth.contradictions as i32,
602 created_by: truth.created_by.clone(),
603 deleted: truth.deleted,
604 version: truth.version as i32,
605 created_at: created.to_rfc3339(),
606 updated_at: created.to_rfc3339(),
607 last_used: used.to_rfc3339(),
608 },
609 })
610 }
611
612 pub async fn reinforce(
613 &mut self,
614 truth_id: &str,
615 _context: Option<&str>,
616 ) -> Result<ReinforcementResponse> {
617 self.reinforced.push(truth_id.to_string());
618 Ok(ReinforcementResponse {
619 truth: None,
620 message: "Reinforced".to_string(),
621 })
622 }
623
624 pub async fn contradict(
625 &mut self,
626 truth_id: &str,
627 _reason: Option<&str>,
628 _context: Option<&str>,
629 ) -> Result<ContradictionResponse> {
630 self.contradicted.push(truth_id.to_string());
631 Ok(ContradictionResponse {
632 truth: None,
633 message: "Contradicted".to_string(),
634 was_deleted: false,
635 })
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 fn create_test_truth() -> BehavioralTruth {
644 BehavioralTruth::new(
645 TruthCategory::CommandUsage,
646 "test".to_string(),
647 "test rule".to_string(),
648 "test rationale".to_string(),
649 TruthSource::ExplicitCommand,
650 None,
651 )
652 }
653
654 #[tokio::test]
655 async fn test_mock_client() {
656 let mut mock = MockKnowledgeApiClient::new();
657
658 let truth = create_test_truth();
659 let response = mock.submit_truth(&truth).await.unwrap();
660
661 assert_eq!(response.truth.id, truth.id);
662 assert_eq!(mock.submitted.len(), 1);
663 }
664
665 #[tokio::test]
666 async fn test_mock_sync() {
667 let truth = create_test_truth();
668 let mock = MockKnowledgeApiClient::with_truths(vec![truth.clone()]);
669
670 let response = mock.sync(SyncRequest::default()).await.unwrap();
671 assert_eq!(response.truths.len(), 1);
672 assert_eq!(response.truths[0].id, truth.id);
673 }
674
675 #[test]
676 fn test_truth_submission_from_behavioral() {
677 let truth = create_test_truth();
678 let submission = TruthSubmission::from(&truth);
679
680 assert_eq!(submission.category, "command_usage");
681 assert_eq!(submission.source, "explicit_command");
682 assert_eq!(submission.rule, truth.rule);
683 }
684
685 #[test]
686 fn test_server_truth_to_behavioral() {
687 let server = ServerTruth {
688 id: "test-id".to_string(),
689 category: "task_strategy".to_string(),
690 context_pattern: "pattern".to_string(),
691 rule: "rule".to_string(),
692 rationale: "rationale".to_string(),
693 source: "success_pattern".to_string(),
694 confidence: 0.9,
695 reinforcements: 5,
696 contradictions: 1,
697 created_by: Some("user".to_string()),
698 deleted: false,
699 version: 1,
700 created_at: "2024-01-01T00:00:00Z".to_string(),
701 updated_at: "2024-01-01T00:00:00Z".to_string(),
702 last_used: "2024-01-01T00:00:00Z".to_string(),
703 };
704
705 let truth = server.to_behavioral_truth();
706
707 assert_eq!(truth.id, "test-id");
708 assert!(matches!(truth.category, TruthCategory::TaskStrategy));
709 assert!(matches!(truth.source, TruthSource::SuccessPattern));
710 assert_eq!(truth.confidence, 0.9);
711 }
712}