feat(growth): ExperienceExtractor + ProfileUpdater — 结构化经验提取和画像增量更新
This commit is contained in:
115
crates/zclaw-growth/src/experience_extractor.rs
Normal file
115
crates/zclaw-growth/src/experience_extractor.rs
Normal file
@@ -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<Arc<ExperienceStore>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExperienceExtractor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { store: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_store(mut self, store: Arc<ExperienceStore>) -> Self {
|
||||||
|
self.store = Some(store);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 CombinedExtraction 中提取经验并持久化
|
||||||
|
/// LLM 调用已由上层完成,这里只做解析和存储
|
||||||
|
pub async fn persist_experiences(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
extraction: &CombinedExtraction,
|
||||||
|
) -> zclaw_types::Result<usize> {
|
||||||
|
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 个通过置信度过滤
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,8 @@ pub mod storage;
|
|||||||
pub mod retrieval;
|
pub mod retrieval;
|
||||||
pub mod summarizer;
|
pub mod summarizer;
|
||||||
pub mod experience_store;
|
pub mod experience_store;
|
||||||
|
pub mod experience_extractor;
|
||||||
|
pub mod profile_updater;
|
||||||
|
|
||||||
// Re-export main types for convenience
|
// Re-export main types for convenience
|
||||||
pub use types::{
|
pub use types::{
|
||||||
@@ -78,6 +80,14 @@ pub use types::{
|
|||||||
RetrievalResult,
|
RetrievalResult,
|
||||||
UriBuilder,
|
UriBuilder,
|
||||||
effective_importance,
|
effective_importance,
|
||||||
|
ArtifactType,
|
||||||
|
CombinedExtraction,
|
||||||
|
EvolutionEvent,
|
||||||
|
EvolutionEventType,
|
||||||
|
EvolutionStatus,
|
||||||
|
ExperienceCandidate,
|
||||||
|
Outcome,
|
||||||
|
ProfileSignals,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use extractor::{LlmDriverForExtraction, MemoryExtractor};
|
pub use extractor::{LlmDriverForExtraction, MemoryExtractor};
|
||||||
@@ -89,6 +99,8 @@ pub use storage::SqliteStorage;
|
|||||||
pub use experience_store::{Experience, ExperienceStore};
|
pub use experience_store::{Experience, ExperienceStore};
|
||||||
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||||
pub use summarizer::SummaryLlmDriver;
|
pub use summarizer::SummaryLlmDriver;
|
||||||
|
pub use experience_extractor::ExperienceExtractor;
|
||||||
|
pub use profile_updater::UserProfileUpdater;
|
||||||
|
|
||||||
/// Growth system configuration
|
/// Growth system configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
109
crates/zclaw-growth/src/profile_updater.rs
Normal file
109
crates/zclaw-growth/src/profile_updater.rs
Normal file
@@ -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<F>(
|
||||||
|
&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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user