Skip to main content

brainwires_knowledge/knowledge/bks_pks/
api.rs

1//! Server API client for behavioral knowledge synchronization
2//!
3//! Handles communication with the Brainwires server for syncing truths,
4//! submitting new truths, and reporting reinforcements/contradictions.
5
6use super::truth::{BehavioralTruth, TruthCategory, TruthFeedback, TruthSource};
7use anyhow::{Context, Result};
8use reqwest::Client;
9use serde::{Deserialize, Serialize};
10
11/// API client for the behavioral knowledge server
12pub struct KnowledgeApiClient {
13    /// HTTP client
14    client: Client,
15
16    /// Base URL for the API
17    base_url: String,
18
19    /// Authentication token (API key)
20    auth_token: Option<String>,
21}
22
23impl KnowledgeApiClient {
24    /// Create a new API client
25    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    /// Build authorization header
34    fn auth_header(&self) -> Option<String> {
35        self.auth_token.as_ref().map(|t| format!("Bearer {}", t))
36    }
37
38    /// Sync truths from server (bidirectional sync)
39    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    /// Get truths from server
63    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) = &params.category {
68            query_parts.push(format!("category={}", cat));
69        }
70        if let Some(q) = &params.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    /// Submit a new truth to the server
111    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    /// Report reinforcement of a truth
135    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    /// Report contradiction of a truth
174    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    /// Check server health
215    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// ============ Request/Response Types ============
226
227/// Request for sync endpoint
228#[derive(Debug, Clone, Serialize, Deserialize, Default)]
229pub struct SyncRequest {
230    /// ISO timestamp - get truths updated since this time
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub since: Option<String>,
233
234    /// Client identifier
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub client_id: Option<String>,
237
238    /// Minimum confidence threshold
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub min_confidence: Option<f32>,
241
242    /// Max results
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub limit: Option<u32>,
245
246    /// New truths to submit from client
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub truths: Option<Vec<TruthSubmission>>,
249
250    /// Feedback from client
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub feedback: Option<Vec<TruthFeedback>>,
253}
254
255/// Response from sync endpoint
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct SyncResponse {
258    /// Truths updated since the requested timestamp
259    pub truths: Vec<ServerTruth>,
260
261    /// Timestamp to use for next sync
262    pub sync_timestamp: String,
263
264    /// Whether there are more results
265    pub has_more: bool,
266
267    /// Stats about sync
268    #[serde(default)]
269    pub stats: SyncStats,
270}
271
272/// Statistics about a sync operation.
273#[derive(Debug, Clone, Serialize, Deserialize, Default)]
274pub struct SyncStats {
275    /// Number of truths received from server.
276    pub truths_received: u32,
277    /// Number of truths sent to server.
278    pub truths_sent: u32,
279    /// Number of feedback reports sent.
280    pub feedback_sent: u32,
281}
282
283/// Truth as returned from server (snake_case fields)
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct ServerTruth {
286    /// Server-assigned truth ID.
287    pub id: String,
288    /// Truth category (snake_case).
289    pub category: String,
290    /// Context pattern this truth applies to.
291    pub context_pattern: String,
292    /// The behavioral rule.
293    pub rule: String,
294    /// Rationale for this truth.
295    pub rationale: String,
296    /// How the truth was learned.
297    pub source: String,
298    /// Confidence score.
299    pub confidence: f32,
300    /// Number of reinforcements.
301    pub reinforcements: i32,
302    /// Number of contradictions.
303    pub contradictions: i32,
304    /// User who created the truth.
305    pub created_by: Option<String>,
306    /// Whether this truth has been deleted.
307    pub deleted: bool,
308    /// Version number.
309    pub version: i32,
310    /// ISO 8601 creation timestamp.
311    pub created_at: String,
312    /// ISO 8601 last update timestamp.
313    pub updated_at: String,
314    /// ISO 8601 last usage timestamp.
315    pub last_used: String,
316}
317
318impl ServerTruth {
319    /// Convert server truth to local BehavioralTruth
320    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        // Parse ISO timestamps to unix timestamps
340        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/// Params for get truths endpoint
368#[derive(Debug, Clone, Default)]
369pub struct GetTruthsParams {
370    /// Filter by category.
371    pub category: Option<String>,
372    /// Search query string.
373    pub query: Option<String>,
374    /// Minimum confidence threshold.
375    pub min_confidence: Option<f32>,
376    /// Maximum number of results.
377    pub limit: Option<u32>,
378    /// Whether to include statistics.
379    pub stats: bool,
380}
381
382/// Response from get truths endpoint
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct GetTruthsResponse {
385    /// Returned truths.
386    #[serde(default)]
387    pub truths: Vec<ServerTruth>,
388
389    /// Total truth count (when stats=true).
390    #[serde(default)]
391    pub total_truths: Option<u32>,
392    /// Counts by category (when stats=true).
393    #[serde(default)]
394    pub by_category: Option<std::collections::HashMap<String, u32>>,
395    /// Average confidence (when stats=true).
396    #[serde(default)]
397    pub avg_confidence: Option<f32>,
398    /// Total reinforcements (when stats=true).
399    #[serde(default)]
400    pub total_reinforcements: Option<u32>,
401    /// Total contradictions (when stats=true).
402    #[serde(default)]
403    pub total_contradictions: Option<u32>,
404}
405
406/// Truth submission to server
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct TruthSubmission {
409    /// Truth category (snake_case).
410    pub category: String,
411    /// Context pattern.
412    pub context_pattern: String,
413    /// The behavioral rule.
414    pub rule: String,
415    /// Rationale.
416    pub rationale: String,
417    /// How the truth was learned.
418    pub source: String,
419    /// Optional confidence override.
420    #[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/// Response from submit endpoint
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct SubmitResponse {
440    /// The submitted truth as stored on server.
441    pub truth: ServerTruth,
442}
443
444/// Request body for reinforcement
445#[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/// Response from reinforcement
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct ReinforcementResponse {
456    /// Updated truth (if still active).
457    pub truth: Option<ServerTruth>,
458    /// Server message.
459    pub message: String,
460}
461
462/// Request body for contradiction
463#[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/// Response from contradiction
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct ContradictionResponse {
476    /// Updated truth (if still active).
477    pub truth: Option<ServerTruth>,
478    /// Server message.
479    pub message: String,
480    /// Whether the truth was deleted due to low confidence.
481    #[serde(default)]
482    pub was_deleted: bool,
483}
484
485// ============ Helper trait for enum serialization ============
486
487trait 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// ============ Mock client for testing ============
520
521#[cfg(test)]
522#[allow(missing_docs)]
523/// Mock client for testing knowledge API interactions.
524pub 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}