//! 用户画像增量更新器 //! 从 CombinedExtraction 的 profile_signals 提取需要更新的字段 //! 不额外调用 LLM,纯规则驱动 use crate::types::CombinedExtraction; /// 更新类型:字段覆盖 vs 数组追加 #[derive(Debug, Clone, PartialEq)] pub enum ProfileUpdateKind { /// 直接覆盖字段值(industry, communication_style) SetField, /// 追加到 JSON 数组字段(recent_topic, pain_point, preferred_tool) AppendArray, } /// 待更新的画像字段 #[derive(Debug, Clone, PartialEq)] pub struct ProfileFieldUpdate { pub field: String, pub value: String, pub kind: ProfileUpdateKind, } /// 用户画像更新器 /// 从 CombinedExtraction 的 profile_signals 中提取需更新的字段列表 /// 调用方(zclaw-runtime)负责实际写入 UserProfileStore pub struct UserProfileUpdater; impl UserProfileUpdater { pub fn new() -> Self { Self } /// 从提取结果中收集需要更新的画像字段 /// 返回 (field, value, kind) 列表,由调用方根据 kind 选择写入方式 pub fn collect_updates( &self, extraction: &CombinedExtraction, ) -> Vec { let signals = &extraction.profile_signals; let mut updates = Vec::new(); if let Some(ref industry) = signals.industry { updates.push(ProfileFieldUpdate { field: "industry".to_string(), value: industry.clone(), kind: ProfileUpdateKind::SetField, }); } if let Some(ref style) = signals.communication_style { updates.push(ProfileFieldUpdate { field: "communication_style".to_string(), value: style.clone(), kind: ProfileUpdateKind::SetField, }); } if let Some(ref topic) = signals.recent_topic { updates.push(ProfileFieldUpdate { field: "recent_topic".to_string(), value: topic.clone(), kind: ProfileUpdateKind::AppendArray, }); } if let Some(ref pain) = signals.pain_point { updates.push(ProfileFieldUpdate { field: "pain_point".to_string(), value: pain.clone(), kind: ProfileUpdateKind::AppendArray, }); } if let Some(ref tool) = signals.preferred_tool { updates.push(ProfileFieldUpdate { field: "preferred_tool".to_string(), value: tool.clone(), kind: ProfileUpdateKind::AppendArray, }); } updates } } impl Default for UserProfileUpdater { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_collect_updates_industry() { let mut extraction = CombinedExtraction::default(); extraction.profile_signals.industry = Some("healthcare".to_string()); let updater = UserProfileUpdater::new(); let updates = updater.collect_updates(&extraction); assert_eq!(updates.len(), 1); assert_eq!(updates[0].field, "industry"); assert_eq!(updates[0].value, "healthcare"); assert_eq!(updates[0].kind, ProfileUpdateKind::SetField); } #[test] fn test_collect_updates_no_signals() { let extraction = CombinedExtraction::default(); let updater = UserProfileUpdater::new(); let updates = updater.collect_updates(&extraction); assert!(updates.is_empty()); } #[test] fn test_collect_updates_multiple_signals() { 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(); let updates = updater.collect_updates(&extraction); assert_eq!(updates.len(), 2); } #[test] fn test_collect_updates_all_five_dimensions() { let mut extraction = CombinedExtraction::default(); extraction.profile_signals.industry = Some("healthcare".to_string()); extraction.profile_signals.communication_style = Some("concise".to_string()); extraction.profile_signals.recent_topic = Some("报表自动化".to_string()); extraction.profile_signals.pain_point = Some("手动汇总太慢".to_string()); extraction.profile_signals.preferred_tool = Some("researcher".to_string()); let updater = UserProfileUpdater::new(); let updates = updater.collect_updates(&extraction); assert_eq!(updates.len(), 5); let set_fields: Vec<_> = updates .iter() .filter(|u| u.kind == ProfileUpdateKind::SetField) .map(|u| u.field.as_str()) .collect(); let append_fields: Vec<_> = updates .iter() .filter(|u| u.kind == ProfileUpdateKind::AppendArray) .map(|u| u.field.as_str()) .collect(); assert_eq!(set_fields, vec!["industry", "communication_style"]); assert_eq!(append_fields, vec!["recent_topic", "pain_point", "preferred_tool"]); } }