Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
根因: Experience 结构体没有 tool_used 字段,PatternAggregator 从 context 字段提取工具名(语义混淆),导致工具信息不准确。 修复: - experience_store.rs: Experience 添加 tool_used: Option<String> 字段 (#[serde(default)] 兼容旧数据),Experience::new() 初始化为 None - experience_extractor.rs: persist_experiences() 从 ExperienceCandidate 的 tools_used[0] 填充 tool_used,同时填充 industry_context - pattern_aggregator.rs: 改用 tool_used 字段提取工具名,不再误用 context - store_experience() 将 tool_used 加入 keywords 提升搜索命中率
380 lines
13 KiB
Rust
380 lines
13 KiB
Rust
//! 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<String>,
|
||
/// 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<Utc>,
|
||
/// Timestamp of most recent reuse or update.
|
||
pub updated_at: DateTime<Utc>,
|
||
/// Associated industry ID (e.g. "ecommerce", "healthcare").
|
||
#[serde(default)]
|
||
pub industry_context: Option<String>,
|
||
/// Which trigger signal produced this experience.
|
||
#[serde(default)]
|
||
pub source_trigger: Option<String>,
|
||
/// Primary tool/skill used to resolve this pain point.
|
||
#[serde(default)]
|
||
pub tool_used: Option<String>,
|
||
}
|
||
|
||
impl Experience {
|
||
/// Create a new experience with the given fields.
|
||
pub fn new(
|
||
agent_id: &str,
|
||
pain_pattern: &str,
|
||
context: &str,
|
||
solution_steps: Vec<String>,
|
||
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,
|
||
tool_used: 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<VikingAdapter>,
|
||
}
|
||
|
||
impl ExperienceStore {
|
||
/// Create a new store backed by the given VikingAdapter.
|
||
pub fn new(viking: Arc<VikingAdapter>) -> Self {
|
||
Self { viking }
|
||
}
|
||
|
||
/// Get a reference to the underlying VikingAdapter.
|
||
pub fn viking(&self) -> &Arc<VikingAdapter> {
|
||
&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());
|
||
}
|
||
if let Some(ref tool) = exp.tool_used {
|
||
keywords.push(tool.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<Vec<Experience>> {
|
||
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::<Experience>(&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<Vec<Experience>> {
|
||
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::<Experience>(&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");
|
||
}
|
||
}
|