feat(intelligence): Phase 2 学习循环基础 — 触发信号 + 经验行业维度
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- 新增 triggers.rs: 5 种触发信号(痛点确认/正反馈/复杂工具链/用户纠正/行业模式) - ExperienceStore 增加 industry_context + source_trigger 字段 - experience.rs format_for_injection 支持行业标签 - intelligence_hooks.rs 集成触发信号评估 - 17 个测试全通过 (7 trigger + 10 experience)
This commit is contained in:
@@ -42,6 +42,12 @@ pub struct Experience {
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Timestamp of most recent reuse or update.
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Associated industry ID (e.g. "ecommerce", "healthcare").
|
||||
#[serde(default)]
|
||||
pub industry_context: Option<String>,
|
||||
/// Which trigger signal produced this experience.
|
||||
#[serde(default)]
|
||||
pub source_trigger: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
229
desktop/src-tauri/src/intelligence/triggers.rs
Normal file
229
desktop/src-tauri/src/intelligence/triggers.rs
Normal file
@@ -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<String>,
|
||||
/// 检测到的痛点置信度(如有)
|
||||
pub pain_confidence: Option<f64>,
|
||||
/// 用户授权的行业关键词
|
||||
pub industry_keywords: Vec<String>,
|
||||
}
|
||||
|
||||
/// 判断是否触发学习信号(纯规则,零 LLM 调用)
|
||||
///
|
||||
/// 返回匹配到的所有触发信号。空 Vec = 无信号,跳过。
|
||||
pub fn evaluate_triggers(ctx: &TriggerContext) -> Vec<TriggerSignal> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ pub async fn post_conversation_hook(
|
||||
}
|
||||
|
||||
// Step 1.6: Detect pain signals from user message
|
||||
let mut pain_confidence: Option<f64> = 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user