feat(hermes): implement intelligence pipeline — 4 chunks, 684 tests passing
Hermes Intelligence Pipeline closes breakpoints in ZCLAW's existing intelligence components with 4 self-contained modules: Chunk 1 — Self-improvement Loop: - ExperienceStore (zclaw-growth): FTS5+TF-IDF wrapper with scope prefix - ExperienceExtractor (desktop/intelligence): template-based extraction from successful proposals with implicit keyword detection Chunk 2 — User Modeling: - UserProfileStore (zclaw-memory): SQLite-backed structured profiles with industry/role/expertise/comm_style/recent_topics/pain_points - UserProfiler (desktop/intelligence): fact classification by category (Preference/Knowledge/Behavior) with profile summary formatting Chunk 3 — NL Cron Chinese Time Parser: - NlScheduleParser (zclaw-runtime): 6 pattern matchers for Chinese time expressions (每天/每周/工作日/间隔/每月/一次性) producing cron expressions - Period-aware hour adjustment (下午3点→15, 晚上8点→20) - Schedule intent detection + task description extraction Chunk 4 — Trajectory Compression: - TrajectoryStore (zclaw-memory): trajectory_events + compressed_trajectories - TrajectoryRecorderMiddleware (zclaw-runtime/middleware): priority 650, async non-blocking event recording via tokio::spawn - TrajectoryCompressor (desktop/intelligence): dedup, request classification, satisfaction detection, execution chain JSON Schema migrations: v2→v3 (user_profiles), v3→v4 (trajectory tables)
This commit is contained in:
356
crates/zclaw-growth/src/experience_store.rs
Normal file
356
crates/zclaw-growth/src/experience_store.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
/// 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());
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ pub mod viking_adapter;
|
||||
pub mod storage;
|
||||
pub mod retrieval;
|
||||
pub mod summarizer;
|
||||
pub mod experience_store;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use types::{
|
||||
@@ -85,6 +86,7 @@ pub use injector::{InjectionFormat, PromptInjector};
|
||||
pub use tracker::{AgentMetadata, GrowthTracker, LearningEvent};
|
||||
pub use viking_adapter::{FindOptions, VikingAdapter, VikingLevel, VikingStorage};
|
||||
pub use storage::SqliteStorage;
|
||||
pub use experience_store::{Experience, ExperienceStore};
|
||||
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||
pub use summarizer::SummaryLlmDriver;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user