From 29fbfbec596b78f73fe393ec9b1a6297344b4986 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 12 Apr 2026 15:52:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(intelligence):=20Phase=202=20=E5=AD=A6?= =?UTF-8?q?=E4=B9=A0=E5=BE=AA=E7=8E=AF=E5=9F=BA=E7=A1=80=20=E2=80=94=20?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E4=BF=A1=E5=8F=B7=20+=20=E7=BB=8F=E9=AA=8C?= =?UTF-8?q?=E8=A1=8C=E4=B8=9A=E7=BB=B4=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 triggers.rs: 5 种触发信号(痛点确认/正反馈/复杂工具链/用户纠正/行业模式) - ExperienceStore 增加 industry_context + source_trigger 字段 - experience.rs format_for_injection 支持行业标签 - intelligence_hooks.rs 集成触发信号评估 - 17 个测试全通过 (7 trigger + 10 experience) --- crates/zclaw-growth/src/experience_store.rs | 11 + .../src-tauri/src/intelligence/experience.rs | 7 +- desktop/src-tauri/src/intelligence/mod.rs | 1 + .../src-tauri/src/intelligence/triggers.rs | 229 ++++++++++++++++++ desktop/src-tauri/src/intelligence_hooks.rs | 24 ++ 5 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/intelligence/triggers.rs diff --git a/crates/zclaw-growth/src/experience_store.rs b/crates/zclaw-growth/src/experience_store.rs index fc26af2..63af9d8 100644 --- a/crates/zclaw-growth/src/experience_store.rs +++ b/crates/zclaw-growth/src/experience_store.rs @@ -42,6 +42,12 @@ pub struct Experience { pub created_at: DateTime, /// Timestamp of most recent reuse or update. pub updated_at: DateTime, + /// Associated industry ID (e.g. "ecommerce", "healthcare"). + #[serde(default)] + pub industry_context: Option, + /// Which trigger signal produced this experience. + #[serde(default)] + pub source_trigger: Option, } impl Experience { @@ -64,6 +70,8 @@ impl Experience { reuse_count: 0, created_at: now, updated_at: now, + industry_context: None, + source_trigger: None, } } @@ -108,6 +116,9 @@ impl ExperienceStore { 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()); + } let entry = MemoryEntry { uri, diff --git a/desktop/src-tauri/src/intelligence/experience.rs b/desktop/src-tauri/src/intelligence/experience.rs index 91e84a8..aa0c20f 100644 --- a/desktop/src-tauri/src/intelligence/experience.rs +++ b/desktop/src-tauri/src/intelligence/experience.rs @@ -204,6 +204,7 @@ impl ExperienceExtractor { /// Format experiences for system prompt injection. /// Returns a concise block capped at ~200 Chinese characters. + /// Includes industry context when available. pub fn format_for_injection( experiences: &[zclaw_growth::experience_store::Experience], ) -> String { @@ -222,8 +223,12 @@ impl ExperienceExtractor { let step_summary = exp.solution_steps.first() .map(|s| truncate(s, 40)) .unwrap_or_default(); + let industry_tag = exp.industry_context.as_ref() + .map(|i| format!(", 行业:{}", i)) + .unwrap_or_default(); let line = format!( - "[过往经验] 类似「{}」做过:{},结果是{}", + "[过往经验{}] 类似「{}」做过:{},结果是{}", + industry_tag, truncate(&exp.pain_pattern, 30), step_summary, exp.outcome diff --git a/desktop/src-tauri/src/intelligence/mod.rs b/desktop/src-tauri/src/intelligence/mod.rs index 7c04e9f..da7763d 100644 --- a/desktop/src-tauri/src/intelligence/mod.rs +++ b/desktop/src-tauri/src/intelligence/mod.rs @@ -37,6 +37,7 @@ pub mod solution_generator; pub mod personality_detector; pub mod pain_storage; pub mod experience; +pub mod triggers; pub mod user_profiler; pub mod trajectory_compressor; diff --git a/desktop/src-tauri/src/intelligence/triggers.rs b/desktop/src-tauri/src/intelligence/triggers.rs new file mode 100644 index 0000000..4c9b978 --- /dev/null +++ b/desktop/src-tauri/src/intelligence/triggers.rs @@ -0,0 +1,229 @@ +//! 学习触发信号系统 +//! +//! 规则驱动的低成本触发判断,在 `post_conversation_hook` 中调用。 +//! 有信号时才进入 LLM 经验提取,无信号则零成本跳过。 + +use super::experience::{CompletionStatus, detect_implicit_feedback}; + +/// 触发信号类型 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TriggerSignal { + /// 痛点确认(confidence >= 0.7) + PainConfirmed, + /// 隐式正反馈("谢谢/解决了/对了/好了") + PositiveFeedback, + /// 复杂工具链(单次对话 3+ tool calls) + ComplexToolChain, + /// 用户纠正(含"不对/不是/应该是") + UserCorrection, + /// 行业模式(同一行业关键词在多轮出现) + IndustryPattern, +} + +/// 触发信号判断的输入 +pub struct TriggerContext { + /// 最新用户消息 + pub user_message: String, + /// 本轮工具调用次数 + pub tool_call_count: usize, + /// 对话中累计的用户消息(用于行业关键词统计) + pub conversation_messages: Vec, + /// 检测到的痛点置信度(如有) + pub pain_confidence: Option, + /// 用户授权的行业关键词 + pub industry_keywords: Vec, +} + +/// 判断是否触发学习信号(纯规则,零 LLM 调用) +/// +/// 返回匹配到的所有触发信号。空 Vec = 无信号,跳过。 +pub fn evaluate_triggers(ctx: &TriggerContext) -> Vec { + let mut signals = Vec::new(); + + // 1. 痛点确认 + if let Some(confidence) = ctx.pain_confidence { + if confidence >= 0.7 { + signals.push(TriggerSignal::PainConfirmed); + } + } + + // 2. 隐式正反馈 + if let Some(status) = detect_implicit_feedback(&ctx.user_message) { + if status == CompletionStatus::Success { + signals.push(TriggerSignal::PositiveFeedback); + } + } + + // 3. 复杂工具链 + if ctx.tool_call_count >= 3 { + signals.push(TriggerSignal::ComplexToolChain); + } + + // 4. 用户纠正 + if is_user_correction(&ctx.user_message) { + signals.push(TriggerSignal::UserCorrection); + } + + // 5. 行业模式 + if detects_industry_pattern(&ctx.conversation_messages, &ctx.industry_keywords) { + signals.push(TriggerSignal::IndustryPattern); + } + + signals +} + +/// 检测用户纠正信号 +fn is_user_correction(message: &str) -> bool { + let lower = message.to_lowercase(); + let correction_patterns = [ + "不对", "不是", "应该是", "错了", "重新", "换一个", + "不是这个", "搞错了", "你理解错了", "我的意思是", + ]; + correction_patterns.iter().any(|p| lower.contains(p)) +} + +/// 检测行业关键词在多轮对话中反复出现 +fn detects_industry_pattern(messages: &[String], industry_keywords: &[String]) -> bool { + if messages.len() < 3 || industry_keywords.is_empty() { + return false; + } + + // 统计行业关键词在所有消息中的出现次数 + let mut keyword_hits: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for msg in messages { + let lower = msg.to_lowercase(); + for kw in industry_keywords { + if lower.contains(kw.to_lowercase().as_str()) { + *keyword_hits.entry(kw).or_default() += 1; + } + } + } + + // 至少有 1 个关键词在 3+ 轮中出现 + keyword_hits.values().any(|&count| count >= 3) +} + +/// 触发信号的可读描述(用于日志) +pub fn signal_description(signal: &TriggerSignal) -> &'static str { + match signal { + TriggerSignal::PainConfirmed => "痛点确认", + TriggerSignal::PositiveFeedback => "隐式正反馈", + TriggerSignal::ComplexToolChain => "复杂工具链", + TriggerSignal::UserCorrection => "用户纠正", + TriggerSignal::IndustryPattern => "行业模式", + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pain_confirmed_trigger() { + let ctx = TriggerContext { + user_message: "这个报表还是有问题".to_string(), + tool_call_count: 1, + conversation_messages: vec!["报表太慢".into()], + pain_confidence: Some(0.8), + industry_keywords: vec![], + }; + let signals = evaluate_triggers(&ctx); + assert!(signals.contains(&TriggerSignal::PainConfirmed)); + } + + #[test] + fn test_positive_feedback_trigger() { + let ctx = TriggerContext { + user_message: "好了,解决了!谢谢".to_string(), + tool_call_count: 0, + conversation_messages: vec![], + pain_confidence: None, + industry_keywords: vec![], + }; + let signals = evaluate_triggers(&ctx); + assert!(signals.contains(&TriggerSignal::PositiveFeedback)); + } + + #[test] + fn test_complex_tool_chain_trigger() { + let ctx = TriggerContext { + user_message: "帮我处理一下".to_string(), + tool_call_count: 4, + conversation_messages: vec![], + pain_confidence: None, + industry_keywords: vec![], + }; + let signals = evaluate_triggers(&ctx); + assert!(signals.contains(&TriggerSignal::ComplexToolChain)); + } + + #[test] + fn test_user_correction_trigger() { + let ctx = TriggerContext { + user_message: "不对,应该是另一个方案".to_string(), + tool_call_count: 0, + conversation_messages: vec![], + pain_confidence: None, + industry_keywords: vec![], + }; + let signals = evaluate_triggers(&ctx); + assert!(signals.contains(&TriggerSignal::UserCorrection)); + } + + #[test] + fn test_industry_pattern_trigger() { + let ctx = TriggerContext { + user_message: "库存又不够了".to_string(), + tool_call_count: 0, + conversation_messages: vec![ + "帮我查库存".into(), + "库存数据怎么看".into(), + "库存预警设置".into(), + "库存又不够了".into(), + ], + pain_confidence: None, + industry_keywords: vec!["库存".to_string(), "SKU".to_string(), "GMV".to_string()], + }; + let signals = evaluate_triggers(&ctx); + assert!(signals.contains(&TriggerSignal::IndustryPattern)); + } + + #[test] + fn test_no_trigger() { + let ctx = TriggerContext { + user_message: "今天天气怎么样".to_string(), + tool_call_count: 0, + conversation_messages: vec![], + pain_confidence: None, + industry_keywords: vec![], + }; + let signals = evaluate_triggers(&ctx); + assert!(signals.is_empty()); + } + + #[test] + fn test_multiple_triggers() { + let ctx = TriggerContext { + user_message: "不对,帮我重新做一下库存报表".to_string(), + tool_call_count: 3, + conversation_messages: vec![ + "库存报表".into(), + "帮我做库存报表".into(), + "库存报表数据".into(), + "不对,帮我重新做一下库存报表".into(), + ], + pain_confidence: Some(0.8), + industry_keywords: vec!["库存".to_string()], + }; + let signals = evaluate_triggers(&ctx); + assert!(signals.contains(&TriggerSignal::PainConfirmed)); + assert!(signals.contains(&TriggerSignal::ComplexToolChain)); + assert!(signals.contains(&TriggerSignal::UserCorrection)); + assert!(signals.contains(&TriggerSignal::IndustryPattern)); + assert!(signals.len() >= 3); + } +} diff --git a/desktop/src-tauri/src/intelligence_hooks.rs b/desktop/src-tauri/src/intelligence_hooks.rs index ea2e9d0..a4be413 100644 --- a/desktop/src-tauri/src/intelligence_hooks.rs +++ b/desktop/src-tauri/src/intelligence_hooks.rs @@ -79,6 +79,7 @@ pub async fn post_conversation_hook( } // Step 1.6: Detect pain signals from user message + let mut pain_confidence: Option = None; if !_user_message.is_empty() { let messages = vec![zclaw_types::Message::user(_user_message)]; if let Some(analysis) = crate::intelligence::pain_aggregator::analyze_for_pain_signals(&messages) { @@ -101,6 +102,7 @@ pub async fn post_conversation_hook( "[intelligence_hooks] Pain point recorded: {} (confidence: {:.2}, count: {})", pain.summary, pain.confidence, pain.occurrence_count ); + pain_confidence = Some(pain.confidence); } Err(e) => { warn!("[intelligence_hooks] Failed to record pain point: {}", e); @@ -109,6 +111,28 @@ pub async fn post_conversation_hook( } } + // Step 1.7: Evaluate learning triggers (rule-based, zero LLM cost) + if !_user_message.is_empty() { + let trigger_ctx = crate::intelligence::triggers::TriggerContext { + user_message: _user_message.to_string(), + tool_call_count: 0, // Will be populated from trajectory recorder in future + conversation_messages: vec![_user_message.to_string()], + pain_confidence, + industry_keywords: vec![], // Will be populated from industry config in Phase 3 + }; + let signals = crate::intelligence::triggers::evaluate_triggers(&trigger_ctx); + if !signals.is_empty() { + let signal_names: Vec<&str> = signals.iter() + .map(crate::intelligence::triggers::signal_description) + .collect(); + debug!( + "[intelligence_hooks] Learning triggers activated: {:?}", + signal_names + ); + // Future: Pass signals to LLM experience extraction (Phase 5) + } + } + // Step 2: Record conversation for reflection let mut engine = reflection_state.lock().await;