//! Pain Point Aggregator — detect, merge, and score recurring user difficulties. //! //! Collects pain signals from conversations, merges similar ones with increasing //! confidence, and surfaces high-confidence pain points for solution generation. use std::sync::Arc; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tracing::debug; use uuid::Uuid; use zclaw_types::Result; use super::pain_storage::PainStorage; // --------------------------------------------------------------------------- // Data structures // --------------------------------------------------------------------------- /// A recurring difficulty or systemic problem the user encounters. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PainPoint { pub id: String, pub agent_id: String, pub user_id: String, pub summary: String, pub category: String, pub severity: PainSeverity, pub evidence: Vec, pub occurrence_count: u32, pub first_seen: DateTime, pub last_seen: DateTime, pub confidence: f64, pub status: PainStatus, } /// A single piece of evidence supporting a pain point. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PainEvidence { pub when: DateTime, pub user_said: String, pub why_flagged: String, } /// How severe the pain point is. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum PainSeverity { #[default] Low, Medium, High, } /// Lifecycle status of a pain point. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PainStatus { Detected, Confirmed, Solving, Solved, Dismissed, } impl PainPoint { /// Create a new pain point with a single evidence entry. pub fn new( agent_id: &str, user_id: &str, summary: &str, category: &str, severity: PainSeverity, user_said: &str, why_flagged: &str, ) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4().to_string(), agent_id: agent_id.to_string(), user_id: user_id.to_string(), summary: summary.to_string(), category: category.to_string(), severity, evidence: vec![PainEvidence { when: now, user_said: user_said.to_string(), why_flagged: why_flagged.to_string(), }], occurrence_count: 1, first_seen: now, last_seen: now, confidence: 0.25, status: PainStatus::Detected, } } } // --------------------------------------------------------------------------- // LLM analysis result // --------------------------------------------------------------------------- /// Result of LLM-based pain point analysis on a conversation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PainAnalysisResult { pub found: bool, #[serde(default)] pub summary: String, #[serde(default)] pub category: String, #[serde(default)] pub severity: PainSeverity, #[serde(default)] pub evidence: String, } // --------------------------------------------------------------------------- // PainAggregator — cross-session merge + confidence scoring // --------------------------------------------------------------------------- /// Aggregates pain points across conversations, merging similar ones /// and escalating confidence as evidence accumulates. /// /// When the global `PAIN_STORAGE` is initialized (via `init_pain_storage`), /// writes are dual: memory Vec (hot cache) + SQLite (durable). pub struct PainAggregator { pain_points: Arc>>, } impl PainAggregator { pub fn new() -> Self { Self { pain_points: Arc::new(RwLock::new(Vec::new())), } } /// Get the global pain storage, if initialized. fn get_storage() -> Option> { PAIN_STORAGE.get().cloned() } /// Load all persisted pain points from storage into the in-memory cache. /// Call this once during app startup after `init_pain_storage()`. pub async fn load_from_storage(&self) -> Result<()> { if let Some(ref storage) = Self::get_storage() { let persisted = storage.load_all_pain_points().await?; let mut points = self.pain_points.write().await; *points = persisted; debug!("[PainAggregator] Loaded {} pain points from storage", points.len()); } Ok(()) } /// Merge a new pain point with an existing similar one, or create a new entry. /// Persists to SQLite if storage is configured. pub async fn merge_or_create(&self, new_pain: PainPoint) -> Result { let mut points = self.pain_points.write().await; let similar_idx = points.iter().position(|p| { p.agent_id == new_pain.agent_id && p.category == new_pain.category && Self::summaries_similar(&p.summary, &new_pain.summary) }); let result = if let Some(idx) = similar_idx { let existing = &mut points[idx]; existing.evidence.extend(new_pain.evidence); existing.occurrence_count += 1; existing.last_seen = Utc::now(); existing.confidence = Self::calculate_confidence(existing); if new_pain.severity as u8 > existing.severity as u8 { existing.severity = new_pain.severity; } if existing.occurrence_count >= 2 && existing.status == PainStatus::Detected { existing.status = PainStatus::Confirmed; } existing.clone() } else { let result = new_pain.clone(); points.push(new_pain); result }; // Dual-write: persist to SQLite if let Some(storage) = Self::get_storage() { if let Err(e) = storage.store_pain_point(&result).await { debug!("[PainAggregator] Failed to persist pain point: {}", e); } } Ok(result) } /// Get all high-confidence pain points for an agent. pub async fn get_high_confidence_pains( &self, agent_id: &str, min_confidence: f64, ) -> Vec { let points = self.pain_points.read().await; points .iter() .filter(|p| { p.agent_id == agent_id && p.confidence >= min_confidence && p.status != PainStatus::Solved && p.status != PainStatus::Dismissed }) .cloned() .collect() } /// List all pain points for an agent. pub async fn list_pain_points(&self, agent_id: &str) -> Vec { let points = self.pain_points.read().await; points .iter() .filter(|p| p.agent_id == agent_id) .cloned() .collect() } /// List all pain points across all agents (for internal use). pub async fn list_all_pain_points(&self) -> Vec { let points = self.pain_points.read().await; points.clone() } /// Update the status of a pain point. Persists to SQLite if configured. pub async fn update_status(&self, pain_id: &str, status: PainStatus) -> Result<()> { let mut points = self.pain_points.write().await; if let Some(p) = points.iter_mut().find(|p| p.id == pain_id) { p.status = status; // Dual-write if let Some(storage) = Self::get_storage() { if let Err(e) = storage.store_pain_point(p).await { debug!("[PainAggregator] Failed to persist status update: {}", e); } } } Ok(()) } // -- internal helpers ---------------------------------------------------- fn summaries_similar(a: &str, b: &str) -> bool { let a_lower = a.to_lowercase(); let b_lower = b.to_lowercase(); if a_lower.contains(&b_lower) || b_lower.contains(&a_lower) { return true; } let a_ngrams = Self::char_ngrams(&a_lower, 2); let b_ngrams = Self::char_ngrams(&b_lower, 2); if a_ngrams.is_empty() || b_ngrams.is_empty() { return false; } let shared = a_ngrams.iter().filter(|ng| b_ngrams.contains(ng)).count(); let overlap_ratio = shared as f64 / a_ngrams.len().min(b_ngrams.len()) as f64; overlap_ratio >= 0.4 } fn char_ngrams(text: &str, n: usize) -> Vec { let chars: Vec = text.chars().collect(); if chars.len() < n { return Vec::new(); } chars.windows(n).map(|w| w.iter().collect()).collect() } fn calculate_confidence(pain: &PainPoint) -> f64 { let base = (pain.occurrence_count as f64 * 0.25).min(0.75); let days_since_last = (Utc::now() - pain.last_seen).num_days().max(0) as f64; let decay = 1.0 - (days_since_last / 90.0); let severity_boost = match pain.severity { PainSeverity::High => 0.1, PainSeverity::Medium => 0.05, PainSeverity::Low => 0.0, }; (base * decay.max(0.0) + severity_boost).min(1.0) } } impl Default for PainAggregator { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // Rule-based pain signal detection // --------------------------------------------------------------------------- const FRUSTRATION_SIGNALS: &[&str] = &[ "烦死了", "又来了", "每次都", "受不了", "还是不行", "又出错", "第三次", "第四次", "第五次", "怎么又", "老是", "一直", "还是没解决", "退了", "被退", "又要改", "搞不定", "没办法", ]; /// Analyze messages for pain signals using rule-based detection. pub fn analyze_for_pain_signals(messages: &[zclaw_types::Message]) -> Option { let mut user_frustrations: Vec = Vec::new(); for msg in messages { if let zclaw_types::Message::User { content } = msg { for signal in FRUSTRATION_SIGNALS { if content.contains(signal) { user_frustrations.push(content.clone()); break; } } } } if user_frustrations.is_empty() { return None; } let combined = user_frustrations.join(" "); let severity = if combined.contains("烦死了") || combined.contains("受不了") || user_frustrations.len() >= 3 { PainSeverity::High } else if user_frustrations.len() >= 2 { PainSeverity::Medium } else { PainSeverity::Low }; let summary = extract_summary(&user_frustrations[0]); let category = classify_category(&combined); Some(PainAnalysisResult { found: true, summary, category, severity, evidence: user_frustrations.join("; "), }) } fn extract_summary(text: &str) -> String { let trimmed = text.trim(); if trimmed.chars().count() <= 50 { trimmed.to_string() } else { let end = trimmed.char_indices().take_while(|(i, _)| *i < 50).last(); match end { Some((i, c)) => trimmed[..i + c.len_utf8()].to_string() + "…", None => trimmed.chars().take(50).collect::() + "…", } } } fn classify_category(text: &str) -> String { let lower = text.to_lowercase(); if ["包", "货", "出口", "退"].iter().any(|k| lower.contains(k)) { "logistics".to_string() } else if ["合规", "法规", "标准"].iter().any(|k| lower.contains(k)) { "compliance".to_string() } else if ["客户", "投诉", "反馈"].iter().any(|k| lower.contains(k)) { "customer".to_string() } else if ["报价", "价格", "成本"].iter().any(|k| lower.contains(k)) { "pricing".to_string() } else if ["系统", "软件", "电脑"].iter().any(|k| lower.contains(k)) { "technology".to_string() } else { "general".to_string() } } // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- use std::sync::OnceLock; use super::solution_generator::{Proposal, ProposalStatus, SolutionGenerator}; static PAIN_AGGREGATOR: OnceLock> = OnceLock::new(); static SOLUTION_GENERATOR: OnceLock> = OnceLock::new(); pub(crate) static PAIN_STORAGE: OnceLock> = OnceLock::new(); fn pain_store() -> Arc { PAIN_AGGREGATOR.get_or_init(|| Arc::new(PainAggregator::new())).clone() } fn solution_store() -> Arc { SOLUTION_GENERATOR.get_or_init(|| Arc::new(SolutionGenerator::new())).clone() } /// Initialize pain point persistence with a SQLite pool. /// /// Creates the schema, sets the global storage, and loads any previously /// persisted data into the in-memory caches. /// /// Should be called once during app startup, before any pain-related operations. pub async fn init_pain_storage(pool: sqlx::SqlitePool) -> Result<()> { let storage = Arc::new(PainStorage::new(pool)); storage.initialize_schema().await?; // Set global storage (must succeed on first call) PAIN_STORAGE.set(storage.clone()).map_err(|_| zclaw_types::ZclawError::StorageError("PainStorage already initialized".into()))?; // Warm the in-memory caches from SQLite let aggregator = pain_store(); aggregator.load_from_storage().await?; let generator = solution_store(); generator.load_from_storage().await?; debug!("[init_pain_storage] Pain storage initialized successfully"); Ok(()) } /// List all pain points for an agent. // @reserved: no frontend UI yet #[tauri::command] pub async fn butler_list_pain_points(agent_id: String) -> std::result::Result, String> { Ok(pain_store().list_pain_points(&agent_id).await) } /// Record a new pain point from conversation analysis. // @reserved: no frontend UI yet #[tauri::command] pub async fn butler_record_pain_point( agent_id: String, user_id: String, summary: String, category: String, severity: String, user_said: String, why_flagged: String, ) -> std::result::Result { let sev = match severity.as_str() { "high" => PainSeverity::High, "medium" => PainSeverity::Medium, _ => PainSeverity::Low, }; let pain = PainPoint::new(&agent_id, &user_id, &summary, &category, sev, &user_said, &why_flagged); pain_store().merge_or_create(pain).await.map_err(|e| e.to_string()) } /// List all proposals for an agent. // @reserved: no frontend UI yet #[tauri::command] pub async fn butler_list_proposals(agent_id: String) -> std::result::Result, String> { let store = pain_store(); let gen = solution_store(); let pains = store.list_pain_points(&agent_id).await; let mut all_proposals = Vec::new(); for pain in &pains { let proposals = gen.list_proposals(&[pain.id.clone()]).await; all_proposals.extend(proposals); } Ok(all_proposals) } /// Generate a solution for a high-confidence pain point. // @reserved: no frontend UI yet #[tauri::command] pub async fn butler_generate_solution(pain_id: String) -> std::result::Result { let store = pain_store(); let gen = solution_store(); let all_pains = store.list_all_pain_points().await; let pain = all_pains .into_iter() .find(|p| p.id == pain_id) .ok_or_else(|| format!("Pain point {} not found", pain_id))?; if pain.confidence < 0.7 { return Err("Pain point confidence too low to generate solution".into()); } let proposal = gen.generate_solution(&pain).await; store .update_status(&pain_id, PainStatus::Solving) .await .map_err(|e| e.to_string())?; Ok(proposal) } /// Update the status of a proposal. // @reserved: no frontend UI yet #[tauri::command] pub async fn butler_update_proposal_status( proposal_id: String, status: String, ) -> std::result::Result<(), String> { let new_status = match status.as_str() { "accepted" => ProposalStatus::Accepted, "rejected" => ProposalStatus::Rejected, "completed" => ProposalStatus::Completed, _ => ProposalStatus::Pending, }; solution_store() .update_status(&proposal_id, new_status) .await .ok_or_else(|| format!("Proposal {} not found", proposal_id))?; Ok(()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use zclaw_types::Message; #[test] fn test_pain_point_serialization() { let pp = PainPoint::new( "agent-1", "user-1", "出口包装合规反复出错", "compliance", PainSeverity::High, "又要改包装", "用户第三次提到包装问题", ); assert_eq!(pp.summary, "出口包装合规反复出错"); assert_eq!(pp.occurrence_count, 1); assert_eq!(pp.status, PainStatus::Detected); let json = serde_json::to_string(&pp).unwrap(); let decoded: PainPoint = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.summary, "出口包装合规反复出错"); assert_eq!(decoded.severity, PainSeverity::High); } #[test] fn test_merge_similar_pain_points() { let aggregator = PainAggregator::new(); let rt = tokio::runtime::Runtime::new().unwrap(); let p1 = PainPoint::new("agent-1", "user-1", "出口包装不合格", "compliance", PainSeverity::Medium, "包装被退了", "用户首次提到"); let merged1 = rt.block_on(aggregator.merge_or_create(p1)).unwrap(); assert_eq!(merged1.occurrence_count, 1); let p2 = PainPoint::new("agent-1", "user-1", "包装不合格又要改", "compliance", PainSeverity::High, "又要改包装", "用户第二次提到"); let merged2 = rt.block_on(aggregator.merge_or_create(p2)).unwrap(); assert_eq!(merged2.occurrence_count, 2); assert!(merged2.confidence > 0.3); assert_eq!(merged2.status, PainStatus::Confirmed); } #[test] fn test_different_categories_not_merged() { let aggregator = PainAggregator::new(); let rt = tokio::runtime::Runtime::new().unwrap(); let p1 = PainPoint::new("agent-1", "user-1", "出口包装不合格", "compliance", PainSeverity::Medium, "包装被退", "包装问题"); let p2 = PainPoint::new("agent-1", "user-1", "客户投诉服务态度", "customer", PainSeverity::High, "客户又投诉了", "客户问题"); rt.block_on(aggregator.merge_or_create(p1)).unwrap(); let merged2 = rt.block_on(aggregator.merge_or_create(p2)).unwrap(); assert_eq!(merged2.occurrence_count, 1); } #[test] fn test_confidence_calculation() { let mut pain = PainPoint::new("agent-1", "user-1", "test", "general", PainSeverity::High, "test", "test"); pain.occurrence_count = 3; pain.last_seen = Utc::now(); let conf = PainAggregator::calculate_confidence(&pain); assert!(conf > 0.5, "3 occurrences + High severity should be > 0.5, got {}", conf); assert!(conf <= 1.0); } #[test] fn test_analyze_frustration_signals() { let messages = vec![ Message::user("这批货又被退了"), Message::assistant("很抱歉听到这个消息"), Message::user("又要改包装,烦死了,第三次了"), ]; let result = analyze_for_pain_signals(&messages); assert!(result.is_some()); let analysis = result.unwrap(); assert!(analysis.found); assert_eq!(analysis.severity, PainSeverity::High); assert!(!analysis.summary.is_empty()); } #[test] fn test_analyze_no_frustration() { let messages = vec![ Message::user("今天天气不错"), Message::assistant("是的,天气很好"), ]; assert!(analyze_for_pain_signals(&messages).is_none()); } #[test] fn test_classify_category() { assert_eq!(classify_category("出口包装被退了"), "logistics"); assert_eq!(classify_category("合规标准变了"), "compliance"); assert_eq!(classify_category("客户投诉很多"), "customer"); assert_eq!(classify_category("报价太低了"), "pricing"); assert_eq!(classify_category("系统又崩了"), "technology"); assert_eq!(classify_category("随便聊聊天"), "general"); } #[test] fn test_severity_ordering() { let messages = vec![ Message::user("这又来了"), Message::user("还是不行"), ]; let result = analyze_for_pain_signals(&messages); assert!(result.is_some()); assert_eq!(result.unwrap().severity, PainSeverity::Medium); } #[test] fn test_get_high_confidence_filters() { let aggregator = PainAggregator::new(); let rt = tokio::runtime::Runtime::new().unwrap(); for i in 0..3 { let p = PainPoint::new("agent-1", "user-1", "包装不合格反复出错", "compliance", PainSeverity::High, &format!("第{}次出现", i + 1), "recurring"); rt.block_on(aggregator.merge_or_create(p)).unwrap(); } let high = rt.block_on(aggregator.get_high_confidence_pains("agent-1", 0.5)); assert!(!high.is_empty()); assert!(high[0].confidence >= 0.5); assert_eq!(high[0].occurrence_count, 3); } }