From e2d44ecf52bfaa8d312c503e042f121b2e46e07c Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 18 Apr 2026 20:51:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(growth):=20ExperienceExtractor=20+=20Profi?= =?UTF-8?q?leUpdater=20=E2=80=94=20=E7=BB=93=E6=9E=84=E5=8C=96=E7=BB=8F?= =?UTF-8?q?=E9=AA=8C=E6=8F=90=E5=8F=96=E5=92=8C=E7=94=BB=E5=83=8F=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zclaw-growth/src/experience_extractor.rs | 115 ++++++++++++++++++ crates/zclaw-growth/src/lib.rs | 12 ++ crates/zclaw-growth/src/profile_updater.rs | 109 +++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 crates/zclaw-growth/src/experience_extractor.rs create mode 100644 crates/zclaw-growth/src/profile_updater.rs diff --git a/crates/zclaw-growth/src/experience_extractor.rs b/crates/zclaw-growth/src/experience_extractor.rs new file mode 100644 index 0000000..3b9b2cc --- /dev/null +++ b/crates/zclaw-growth/src/experience_extractor.rs @@ -0,0 +1,115 @@ +//! 结构化经验提取器 +//! 从对话中提取 ExperienceCandidate(pain_pattern → solution_steps → outcome) +//! 持久化到 ExperienceStore + +use std::sync::Arc; + +use crate::experience_store::ExperienceStore; +use crate::types::{CombinedExtraction, ExperienceCandidate, Outcome}; + +/// 结构化经验提取器 +/// LLM 调用已由上层 MemoryExtractor 完成,这里只做解析和持久化 +pub struct ExperienceExtractor { + store: Option>, +} + +impl ExperienceExtractor { + pub fn new() -> Self { + Self { store: None } + } + + pub fn with_store(mut self, store: Arc) -> Self { + self.store = Some(store); + self + } + + /// 从 CombinedExtraction 中提取经验并持久化 + /// LLM 调用已由上层完成,这里只做解析和存储 + pub async fn persist_experiences( + &self, + agent_id: &str, + extraction: &CombinedExtraction, + ) -> zclaw_types::Result { + let store = match &self.store { + Some(s) => s, + None => return Ok(0), + }; + + let mut count = 0; + for candidate in &extraction.experiences { + if candidate.confidence < 0.6 { + continue; + } + let outcome_str = match candidate.outcome { + Outcome::Success => "success", + Outcome::Partial => "partial", + Outcome::Failed => "failed", + }; + let exp = crate::experience_store::Experience::new( + agent_id, + &candidate.pain_pattern, + &candidate.context, + candidate.solution_steps.clone(), + outcome_str, + ); + store.store_experience(&exp).await?; + count += 1; + } + Ok(count) + } +} + +impl Default for ExperienceExtractor { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extractor_new_without_store() { + let ext = ExperienceExtractor::new(); + assert!(ext.store.is_none()); + } + + #[tokio::test] + async fn test_persist_no_store_returns_zero() { + let ext = ExperienceExtractor::new(); + let extraction = CombinedExtraction::default(); + let count = ext.persist_experiences("agent1", &extraction).await.unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn test_persist_filters_low_confidence() { + let viking = Arc::new(crate::VikingAdapter::in_memory()); + let store = Arc::new(ExperienceStore::new(viking)); + let ext = ExperienceExtractor::new().with_store(store); + + let mut extraction = CombinedExtraction::default(); + extraction.experiences.push(ExperienceCandidate { + pain_pattern: "low confidence task".to_string(), + context: "should be filtered".to_string(), + solution_steps: vec!["step1".to_string()], + outcome: Outcome::Success, + confidence: 0.3, // 低于 0.6 阈值 + tools_used: vec![], + industry_context: None, + }); + extraction.experiences.push(ExperienceCandidate { + pain_pattern: "high confidence task".to_string(), + context: "should be stored".to_string(), + solution_steps: vec!["step1".to_string(), "step2".to_string()], + outcome: Outcome::Success, + confidence: 0.9, + tools_used: vec!["researcher".to_string()], + industry_context: Some("healthcare".to_string()), + }); + + let count = ext.persist_experiences("agent-1", &extraction).await.unwrap(); + assert_eq!(count, 1); // 只有 1 个通过置信度过滤 + } +} diff --git a/crates/zclaw-growth/src/lib.rs b/crates/zclaw-growth/src/lib.rs index 65f5aaf..6640613 100644 --- a/crates/zclaw-growth/src/lib.rs +++ b/crates/zclaw-growth/src/lib.rs @@ -65,6 +65,8 @@ pub mod storage; pub mod retrieval; pub mod summarizer; pub mod experience_store; +pub mod experience_extractor; +pub mod profile_updater; // Re-export main types for convenience pub use types::{ @@ -78,6 +80,14 @@ pub use types::{ RetrievalResult, UriBuilder, effective_importance, + ArtifactType, + CombinedExtraction, + EvolutionEvent, + EvolutionEventType, + EvolutionStatus, + ExperienceCandidate, + Outcome, + ProfileSignals, }; pub use extractor::{LlmDriverForExtraction, MemoryExtractor}; @@ -89,6 +99,8 @@ pub use storage::SqliteStorage; pub use experience_store::{Experience, ExperienceStore}; pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer}; pub use summarizer::SummaryLlmDriver; +pub use experience_extractor::ExperienceExtractor; +pub use profile_updater::UserProfileUpdater; /// Growth system configuration #[derive(Debug, Clone)] diff --git a/crates/zclaw-growth/src/profile_updater.rs b/crates/zclaw-growth/src/profile_updater.rs new file mode 100644 index 0000000..e4c2f32 --- /dev/null +++ b/crates/zclaw-growth/src/profile_updater.rs @@ -0,0 +1,109 @@ +//! 用户画像增量更新器 +//! 从 CombinedExtraction 的 profile_signals 更新 UserProfileStore +//! 不额外调用 LLM,纯规则驱动 + +use crate::types::CombinedExtraction; + +/// 用户画像更新器 +/// 接收 CombinedExtraction 中的 profile_signals,通过回调函数更新画像 +pub struct UserProfileUpdater; + +impl UserProfileUpdater { + pub fn new() -> Self { + Self + } + + /// 从提取结果更新用户画像 + /// profile_store 通过闭包注入,避免 zclaw-growth 依赖 zclaw-memory + pub async fn update( + &self, + user_id: &str, + extraction: &CombinedExtraction, + update_fn: F, + ) -> zclaw_types::Result<()> + where + F: Fn(&str, &str, &str) -> zclaw_types::Result<()> + Send + Sync, + { + let signals = &extraction.profile_signals; + + if let Some(ref industry) = signals.industry { + update_fn(user_id, "industry", industry)?; + } + + if let Some(ref style) = signals.communication_style { + update_fn(user_id, "communication_style", style)?; + } + + // pain_point 和 preferred_tool 使用单独的方法(有去重和容量限制) + // 这些通过 GrowthIntegration 中的具体调用处理 + + Ok(()) + } +} + +impl Default for UserProfileUpdater { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[tokio::test] + async fn test_update_industry() { + let calls = Arc::new(Mutex::new(Vec::new())); + let calls_clone = calls.clone(); + let update_fn = move |uid: &str, field: &str, val: &str| -> zclaw_types::Result<()> { + calls_clone + .lock() + .unwrap() + .push((uid.to_string(), field.to_string(), val.to_string())); + Ok(()) + }; + let mut extraction = CombinedExtraction::default(); + extraction.profile_signals.industry = Some("healthcare".to_string()); + + let updater = UserProfileUpdater::new(); + updater.update("user1", &extraction, update_fn).await.unwrap(); + + let locked = calls.lock().unwrap(); + assert_eq!(locked.len(), 1); + assert_eq!(locked[0].1, "industry"); + assert_eq!(locked[0].2, "healthcare"); + } + + #[tokio::test] + async fn test_update_no_signals() { + let update_fn = + |_: &str, _: &str, _: &str| -> zclaw_types::Result<()> { Ok(()) }; + let extraction = CombinedExtraction::default(); + let updater = UserProfileUpdater::new(); + updater.update("user1", &extraction, update_fn).await.unwrap(); + // No panic = pass + } + + #[tokio::test] + async fn test_update_multiple_signals() { + let calls = Arc::new(Mutex::new(Vec::new())); + let calls_clone = calls.clone(); + let update_fn = move |uid: &str, field: &str, val: &str| -> zclaw_types::Result<()> { + calls_clone + .lock() + .unwrap() + .push((uid.to_string(), field.to_string(), val.to_string())); + Ok(()) + }; + let mut extraction = CombinedExtraction::default(); + extraction.profile_signals.industry = Some("ecommerce".to_string()); + extraction.profile_signals.communication_style = Some("concise".to_string()); + + let updater = UserProfileUpdater::new(); + updater.update("user1", &extraction, update_fn).await.unwrap(); + + let locked = calls.lock().unwrap(); + assert_eq!(locked.len(), 2); + } +}