//! ExperienceStore — CRUD wrapper over VikingStorage for agent experiences. //! //! Stores structured experiences extracted from successful solution proposals //! using the scope prefix `agent://{agent_id}/experience/{pattern_hash}`. //! Leverages existing FTS5 + TF-IDF + embedding retrieval via VikingAdapter. use std::sync::Arc; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; use uuid::Uuid; use crate::types::{MemoryEntry, MemoryType}; use crate::viking_adapter::{FindOptions, VikingAdapter}; // --------------------------------------------------------------------------- // Experience data model // --------------------------------------------------------------------------- /// A structured experience record representing a solved pain point. /// /// Stored as JSON content inside a VikingStorage `MemoryEntry` with /// `memory_type = Experience`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Experience { /// Unique experience identifier. pub id: String, /// Owning agent. pub agent_id: String, /// Short pattern describing the pain that was solved (e.g. "logistics export packaging"). pub pain_pattern: String, /// Context in which the problem occurred. pub context: String, /// Ordered steps that resolved the problem. pub solution_steps: Vec, /// Verbal outcome reported by the user. pub outcome: String, /// How many times this experience has been reused as a reference. pub reuse_count: u32, /// Timestamp of initial creation. pub created_at: DateTime, /// Timestamp of most recent reuse or update. pub updated_at: DateTime, /// Associated industry ID (e.g. "ecommerce", "healthcare"). #[serde(default)] pub industry_context: Option, /// Which trigger signal produced this experience. #[serde(default)] pub source_trigger: Option, } impl Experience { /// Create a new experience with the given fields. pub fn new( agent_id: &str, pain_pattern: &str, context: &str, solution_steps: Vec, outcome: &str, ) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4().to_string(), agent_id: agent_id.to_string(), pain_pattern: pain_pattern.to_string(), context: context.to_string(), solution_steps, outcome: outcome.to_string(), reuse_count: 0, created_at: now, updated_at: now, industry_context: None, source_trigger: None, } } /// Deterministic URI for this experience, keyed on a stable hash of the /// pain pattern so duplicate patterns overwrite the same entry. pub fn uri(&self) -> String { let hash = simple_hash(&self.pain_pattern); format!("agent://{}/experience/{}", self.agent_id, hash) } } /// FNV-1a–inspired stable 8-hex-char hash. Good enough for deduplication; /// collisions are acceptable because the full `pain_pattern` is still stored. fn simple_hash(s: &str) -> String { let mut h: u32 = 2166136261; for b in s.as_bytes() { h ^= *b as u32; h = h.wrapping_mul(16777619); } format!("{:08x}", h) } // --------------------------------------------------------------------------- // ExperienceStore // --------------------------------------------------------------------------- /// CRUD wrapper that persists [`Experience`] records through [`VikingAdapter`]. pub struct ExperienceStore { viking: Arc, } impl ExperienceStore { /// Create a new store backed by the given VikingAdapter. pub fn new(viking: Arc) -> Self { Self { viking } } /// Store (or overwrite) an experience. The URI is derived from /// `agent_id + pain_pattern`, ensuring one experience per pattern. pub async fn store_experience(&self, exp: &Experience) -> zclaw_types::Result<()> { let uri = exp.uri(); let content = serde_json::to_string(exp)?; let mut keywords = vec![exp.pain_pattern.clone()]; keywords.extend(exp.solution_steps.iter().take(3).cloned()); if let Some(ref industry) = exp.industry_context { keywords.push(industry.clone()); } let entry = MemoryEntry { uri, memory_type: MemoryType::Experience, content, keywords, importance: 8, access_count: 0, created_at: exp.created_at, last_accessed: exp.updated_at, overview: Some(exp.pain_pattern.clone()), abstract_summary: Some(exp.outcome.clone()), }; self.viking.store(&entry).await?; debug!("[ExperienceStore] Stored experience {} for agent {}", exp.id, exp.agent_id); Ok(()) } /// Find experiences whose pain pattern matches the given query. pub async fn find_by_pattern( &self, agent_id: &str, pattern_query: &str, ) -> zclaw_types::Result> { let scope = format!("agent://{}/experience/", agent_id); let opts = FindOptions { scope: Some(scope), limit: Some(10), min_similarity: None, }; let entries = self.viking.find(pattern_query, opts).await?; let mut results = Vec::with_capacity(entries.len()); for entry in entries { match serde_json::from_str::(&entry.content) { Ok(exp) => results.push(exp), Err(e) => warn!("[ExperienceStore] Failed to deserialize experience at {}: {}", entry.uri, e), } } Ok(results) } /// Return all experiences for a given agent. pub async fn find_by_agent( &self, agent_id: &str, ) -> zclaw_types::Result> { let prefix = format!("agent://{}/experience/", agent_id); let entries = self.viking.find_by_prefix(&prefix).await?; let mut results = Vec::with_capacity(entries.len()); for entry in entries { match serde_json::from_str::(&entry.content) { Ok(exp) => results.push(exp), Err(e) => warn!("[ExperienceStore] Failed to deserialize experience at {}: {}", entry.uri, e), } } Ok(results) } /// Increment the reuse counter for an existing experience. /// On failure, logs a warning but does **not** propagate the error so /// callers are never blocked. pub async fn increment_reuse(&self, exp: &Experience) { let mut updated = exp.clone(); updated.reuse_count += 1; updated.updated_at = Utc::now(); if let Err(e) = self.store_experience(&updated).await { warn!("[ExperienceStore] Failed to increment reuse for {}: {}", exp.id, e); } } /// Delete a single experience by its URI. pub async fn delete(&self, exp: &Experience) -> zclaw_types::Result<()> { let uri = exp.uri(); self.viking.delete(&uri).await?; debug!("[ExperienceStore] Deleted experience {} for agent {}", exp.id, exp.agent_id); Ok(()) } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn test_experience_new() { let exp = Experience::new( "agent-1", "logistics export packaging", "export packaging rejected by customs", vec!["check regulations".into(), "use approved materials".into()], "packaging passed customs", ); assert!(!exp.id.is_empty()); assert_eq!(exp.agent_id, "agent-1"); assert_eq!(exp.solution_steps.len(), 2); assert_eq!(exp.reuse_count, 0); } #[test] fn test_uri_deterministic() { let exp1 = Experience::new( "agent-1", "packaging issue", "ctx", vec!["step1".into()], "ok", ); // Second experience with same agent + pattern should produce the same URI. let mut exp2 = exp1.clone(); exp2.id = "different-id".to_string(); assert_eq!(exp1.uri(), exp2.uri()); } #[test] fn test_uri_differs_for_different_patterns() { let exp_a = Experience::new( "agent-1", "packaging issue", "ctx", vec!["step1".into()], "ok", ); let exp_b = Experience::new( "agent-1", "compliance gap", "ctx", vec!["step1".into()], "ok", ); assert_ne!(exp_a.uri(), exp_b.uri()); } #[test] fn test_simple_hash_stability() { let h1 = simple_hash("hello world"); let h2 = simple_hash("hello world"); assert_eq!(h1, h2); assert_eq!(h1.len(), 8); } #[tokio::test] async fn test_store_and_find_by_agent() { let viking = Arc::new(VikingAdapter::in_memory()); let store = ExperienceStore::new(viking); let exp = Experience::new( "agent-42", "export document errors", "recurring mistakes in export docs", vec!["use template".into(), "auto-validate".into()], "no more errors", ); store.store_experience(&exp).await.unwrap(); let found = store.find_by_agent("agent-42").await.unwrap(); assert_eq!(found.len(), 1); assert_eq!(found[0].pain_pattern, "export document errors"); assert_eq!(found[0].solution_steps.len(), 2); } #[tokio::test] async fn test_store_overwrites_same_pattern() { let viking = Arc::new(VikingAdapter::in_memory()); let store = ExperienceStore::new(viking); let exp_v1 = Experience::new( "agent-1", "packaging", "v1", vec!["old step".into()], "ok", ); store.store_experience(&exp_v1).await.unwrap(); let exp_v2 = Experience::new( "agent-1", "packaging", "v2 updated", vec!["new step".into()], "better", ); // Force same URI by reusing the ID logic — same pattern → same URI. store.store_experience(&exp_v2).await.unwrap(); let found = store.find_by_agent("agent-1").await.unwrap(); // Should be overwritten, not duplicated (same URI). assert_eq!(found.len(), 1); assert_eq!(found[0].context, "v2 updated"); } #[tokio::test] async fn test_find_by_pattern() { let viking = Arc::new(VikingAdapter::in_memory()); let store = ExperienceStore::new(viking); let exp = Experience::new( "agent-1", "logistics packaging compliance", "export compliance issues", vec!["check regulations".into()], "passed audit", ); store.store_experience(&exp).await.unwrap(); let found = store.find_by_pattern("agent-1", "packaging").await.unwrap(); assert_eq!(found.len(), 1); } #[tokio::test] async fn test_increment_reuse() { let viking = Arc::new(VikingAdapter::in_memory()); let store = ExperienceStore::new(viking); let exp = Experience::new( "agent-1", "packaging", "ctx", vec!["step".into()], "ok", ); store.store_experience(&exp).await.unwrap(); store.increment_reuse(&exp).await; let found = store.find_by_agent("agent-1").await.unwrap(); assert_eq!(found[0].reuse_count, 1); } #[tokio::test] async fn test_delete_experience() { let viking = Arc::new(VikingAdapter::in_memory()); let store = ExperienceStore::new(viking); let exp = Experience::new( "agent-1", "packaging", "ctx", vec!["step".into()], "ok", ); store.store_experience(&exp).await.unwrap(); store.delete(&exp).await.unwrap(); let found = store.find_by_agent("agent-1").await.unwrap(); assert!(found.is_empty()); } #[tokio::test] async fn test_find_by_agent_filters_other_agents() { let viking = Arc::new(VikingAdapter::in_memory()); let store = ExperienceStore::new(viking); let exp_a = Experience::new("agent-a", "packaging", "ctx", vec!["s".into()], "ok"); let exp_b = Experience::new("agent-b", "compliance", "ctx", vec!["s".into()], "ok"); store.store_experience(&exp_a).await.unwrap(); store.store_experience(&exp_b).await.unwrap(); let found_a = store.find_by_agent("agent-a").await.unwrap(); assert_eq!(found_a.len(), 1); assert_eq!(found_a[0].pain_pattern, "packaging"); } }