//! Experience Extractor — transforms successful proposals into reusable experiences. //! //! Closes Breakpoint 3 (successful solution → structured experience) and //! Breakpoint 4 (experience reuse injection) of the self-improvement loop. //! //! When a user confirms a proposal was helpful (explicitly or via implicit //! keyword detection), the extractor creates an [`Experience`] record and //! stores it through [`ExperienceStore`] for future retrieval. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; use uuid::Uuid; use zclaw_growth::ExperienceStore; use zclaw_types::Result; use super::pain_aggregator::PainPoint; use super::solution_generator::{Proposal, ProposalStatus}; // --------------------------------------------------------------------------- // Shared completion status // --------------------------------------------------------------------------- /// Completion outcome — shared across experience and trajectory modules. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CompletionStatus { Success, Partial, Failed, Abandoned, } // --------------------------------------------------------------------------- // Feedback & event types // --------------------------------------------------------------------------- /// User feedback on a proposal's effectiveness. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProposalFeedback { pub proposal_id: String, pub outcome: CompletionStatus, pub user_comment: Option, pub detected_at: DateTime, } /// Event emitted when a pain point reaches high confidence. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PainConfirmedEvent { pub pain_point_id: String, pub pattern: String, pub confidence: f64, } // --------------------------------------------------------------------------- // Implicit feedback detection // --------------------------------------------------------------------------- const POSITIVE_KEYWORDS: &[&str] = &[ "好了", "解决了", "可以了", "对了", "完美", "谢谢", "很好", "棒", "不错", "成功了", "行了", "搞定了", "OK", "ok", "搞定", ]; const NEGATIVE_KEYWORDS: &[&str] = &[ "没用", "不对", "还是不行", "错了", "差太远", "不好使", "不管用", "没效果", "失败", "不行", ]; /// Detect implicit feedback from user messages. /// Returns `Some(CompletionStatus)` if a clear signal is found. pub fn detect_implicit_feedback(message: &str) -> Option { let lower = message.to_lowercase(); for kw in POSITIVE_KEYWORDS { if lower.contains(kw) { return Some(CompletionStatus::Success); } } for kw in NEGATIVE_KEYWORDS { if lower.contains(kw) { return Some(CompletionStatus::Failed); } } None } // --------------------------------------------------------------------------- // ExperienceExtractor // --------------------------------------------------------------------------- /// Extracts structured experiences from successful proposals. /// /// Two extraction strategies: /// 1. **LLM-assisted** — uses LLM to summarise context + steps (when driver available) /// 2. **Template fallback** — fixed-format extraction from proposal fields pub struct ExperienceExtractor { experience_store: std::sync::Arc, } impl ExperienceExtractor { pub fn new(experience_store: std::sync::Arc) -> Self { Self { experience_store } } /// Extract and store an experience from a successful proposal + pain point. /// /// Uses template extraction as the default strategy. LLM-assisted extraction /// can be added later by wiring a driver through the constructor. pub async fn extract_from_proposal( &self, proposal: &Proposal, pain: &PainPoint, feedback: &ProposalFeedback, ) -> Result<()> { if feedback.outcome != CompletionStatus::Success && feedback.outcome != CompletionStatus::Partial { debug!( "[ExperienceExtractor] Skipping non-success proposal {} ({:?})", proposal.id, feedback.outcome ); return Ok(()); } let experience = self.template_extract(proposal, pain, feedback); self.experience_store.store_experience(&experience).await?; debug!( "[ExperienceExtractor] Stored experience {} for pain '{}'", experience.id, experience.pain_pattern ); Ok(()) } /// Template-based extraction — deterministic, no LLM required. fn template_extract( &self, proposal: &Proposal, pain: &PainPoint, feedback: &ProposalFeedback, ) -> zclaw_growth::experience_store::Experience { let solution_steps: Vec = proposal.steps.iter() .map(|s| { if let Some(ref hint) = s.skill_hint { format!("{} (工具: {})", s.detail, hint) } else { s.detail.clone() } }) .collect(); let context = format!( "痛点: {} | 类别: {} | 出现{}次 | 证据: {}", pain.summary, pain.category, pain.occurrence_count, pain.evidence.iter() .map(|e| e.user_said.as_str()) .collect::>() .join(";") ); let outcome = match feedback.outcome { CompletionStatus::Success => "成功解决", CompletionStatus::Partial => "部分解决", CompletionStatus::Failed => "未解决", CompletionStatus::Abandoned => "已放弃", }; zclaw_growth::experience_store::Experience::new( &pain.agent_id, &pain.summary, &context, solution_steps, outcome, ) } /// Search for relevant experiences to inject into a conversation. /// /// Returns experiences whose pain pattern matches the user's current input. pub async fn find_relevant_experiences( &self, agent_id: &str, user_input: &str, ) -> Vec { match self.experience_store.find_by_pattern(agent_id, user_input).await { Ok(experiences) => { if !experiences.is_empty() { // Increment reuse count for found experiences (fire-and-forget) for exp in &experiences { let store = self.experience_store.clone(); let exp_clone = exp.clone(); tokio::spawn(async move { store.increment_reuse(&exp_clone).await; }); } } experiences } Err(e) => { warn!("[ExperienceExtractor] find_relevant failed: {}", e); Vec::new() } } } /// Format experiences for system prompt injection. /// Returns a concise block capped at ~200 Chinese characters. pub fn format_for_injection( experiences: &[zclaw_growth::experience_store::Experience], ) -> String { if experiences.is_empty() { return String::new(); } let mut parts = Vec::new(); let mut total_chars = 0; let max_chars = 200; for exp in experiences { if total_chars >= max_chars { break; } let step_summary = exp.solution_steps.first() .map(|s| truncate(s, 40)) .unwrap_or_default(); let line = format!( "[过往经验] 类似「{}」做过:{},结果是{}", truncate(&exp.pain_pattern, 30), step_summary, exp.outcome ); total_chars += line.chars().count(); parts.push(line); } if parts.is_empty() { return String::new(); } format!("\n\n--- 过往经验参考 ---\n{}", parts.join("\n")) } } fn truncate(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { s.to_string() } else { s.chars().take(max_chars).collect::() + "…" } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::intelligence::pain_aggregator::PainSeverity; fn sample_pain() -> PainPoint { PainPoint::new( "agent-1", "user-1", "出口包装不合格", "logistics", PainSeverity::High, "又被退了", "recurring packaging issue", ) } fn sample_proposal(pain: &PainPoint) -> Proposal { Proposal::from_pain_point(pain) } #[test] fn test_detect_positive_feedback() { assert_eq!( detect_implicit_feedback("好了,这下解决了"), Some(CompletionStatus::Success) ); assert_eq!( detect_implicit_feedback("谢谢,完美"), Some(CompletionStatus::Success) ); } #[test] fn test_detect_negative_feedback() { assert_eq!( detect_implicit_feedback("还是不行"), Some(CompletionStatus::Failed) ); assert_eq!( detect_implicit_feedback("没用啊"), Some(CompletionStatus::Failed) ); } #[test] fn test_no_feedback() { assert_eq!(detect_implicit_feedback("今天天气怎么样"), None); assert_eq!(detect_implicit_feedback("帮我查一下"), None); } #[test] fn test_template_extract() { let viking = std::sync::Arc::new(zclaw_growth::VikingAdapter::in_memory()); let store = std::sync::Arc::new(ExperienceStore::new(viking)); let extractor = ExperienceExtractor::new(store); let pain = sample_pain(); let proposal = sample_proposal(&pain); let feedback = ProposalFeedback { proposal_id: proposal.id.clone(), outcome: CompletionStatus::Success, user_comment: Some("好了".into()), detected_at: Utc::now(), }; let exp = extractor.template_extract(&proposal, &pain, &feedback); assert!(!exp.id.is_empty()); assert_eq!(exp.agent_id, "agent-1"); assert!(!exp.solution_steps.is_empty()); assert_eq!(exp.outcome, "成功解决"); } #[test] fn test_format_for_injection_empty() { assert!(ExperienceExtractor::format_for_injection(&[]).is_empty()); } #[test] fn test_format_for_injection_with_data() { let exp = zclaw_growth::experience_store::Experience::new( "agent-1", "出口包装问题", "包装被退回", vec!["检查法规".into(), "使用合规材料".into()], "成功解决", ); let formatted = ExperienceExtractor::format_for_injection(&[exp]); assert!(formatted.contains("过往经验")); assert!(formatted.contains("出口包装问题")); } #[tokio::test] async fn test_extract_stores_experience() { let viking = std::sync::Arc::new(zclaw_growth::VikingAdapter::in_memory()); let store = std::sync::Arc::new(ExperienceStore::new(viking)); let extractor = ExperienceExtractor::new(store.clone()); let pain = sample_pain(); let proposal = sample_proposal(&pain); let feedback = ProposalFeedback { proposal_id: proposal.id.clone(), outcome: CompletionStatus::Success, user_comment: Some("好了".into()), detected_at: Utc::now(), }; extractor.extract_from_proposal(&proposal, &pain, &feedback).await.unwrap(); let found = store.find_by_agent("agent-1").await.unwrap(); assert_eq!(found.len(), 1); } #[tokio::test] async fn test_extract_skips_failed_feedback() { let viking = std::sync::Arc::new(zclaw_growth::VikingAdapter::in_memory()); let store = std::sync::Arc::new(ExperienceStore::new(viking)); let extractor = ExperienceExtractor::new(store.clone()); let pain = sample_pain(); let proposal = sample_proposal(&pain); let feedback = ProposalFeedback { proposal_id: proposal.id.clone(), outcome: CompletionStatus::Failed, user_comment: Some("没用".into()), detected_at: Utc::now(), }; extractor.extract_from_proposal(&proposal, &pain, &feedback).await.unwrap(); let found = store.find_by_agent("agent-1").await.unwrap(); assert!(found.is_empty(), "Should not store experience for failed feedback"); } #[test] fn test_truncate() { assert_eq!(truncate("hello", 10), "hello"); assert_eq!(truncate("这是一个很长的字符串用于测试截断", 10).chars().count(), 11); // 10 + … } }