diff --git a/desktop/src-tauri/src/intelligence/mod.rs b/desktop/src-tauri/src/intelligence/mod.rs index 32adcd7..55a6e8d 100644 --- a/desktop/src-tauri/src/intelligence/mod.rs +++ b/desktop/src-tauri/src/intelligence/mod.rs @@ -32,6 +32,8 @@ pub mod reflection; pub mod identity; pub mod validation; pub mod extraction_adapter; +pub mod pain_aggregator; +pub mod solution_generator; // Re-export main types for convenience pub use heartbeat::HeartbeatEngineState; diff --git a/desktop/src-tauri/src/intelligence/pain_aggregator.rs b/desktop/src-tauri/src/intelligence/pain_aggregator.rs new file mode 100644 index 0000000..6b870fd --- /dev/null +++ b/desktop/src-tauri/src/intelligence/pain_aggregator.rs @@ -0,0 +1,570 @@ +//! 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 uuid::Uuid; +use zclaw_types::Result; + +// --------------------------------------------------------------------------- +// 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. +pub struct PainAggregator { + pain_points: Arc>>, +} + +impl PainAggregator { + pub fn new() -> Self { + Self { + pain_points: Arc::new(RwLock::new(Vec::new())), + } + } + + /// Merge a new pain point with an existing similar one, or create a new entry. + 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) + }); + + 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; + } + Ok(existing.clone()) + } else { + let result = new_pain.clone(); + points.push(new_pain); + 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. + 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; + } + 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(); + +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() +} + +/// List all pain points for an agent. +#[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. +#[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. +#[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. +#[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. +#[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); + } +} diff --git a/desktop/src-tauri/src/intelligence/solution_generator.rs b/desktop/src-tauri/src/intelligence/solution_generator.rs new file mode 100644 index 0000000..fee734d --- /dev/null +++ b/desktop/src-tauri/src/intelligence/solution_generator.rs @@ -0,0 +1,343 @@ +//! Solution Generator — transforms high-confidence pain points into actionable proposals. +//! +//! When a PainPoint reaches confidence >= 0.7, the generator creates a Proposal +//! with concrete steps derived from available skills and pipeline templates. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::pain_aggregator::{PainEvidence, PainPoint, PainSeverity}; + +// --------------------------------------------------------------------------- +// Proposal data structures +// --------------------------------------------------------------------------- + +/// A proposed solution for a confirmed pain point. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Proposal { + pub id: String, + pub pain_point_id: String, + pub title: String, + pub description: String, + pub steps: Vec, + pub status: ProposalStatus, + pub evidence_chain: Vec, + pub confidence_at_creation: f64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A single step in a proposal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProposalStep { + pub index: u32, + pub action: String, + pub detail: String, + pub skill_hint: Option, +} + +/// Lifecycle status of a proposal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProposalStatus { + Pending, + Accepted, + Rejected, + Completed, +} + +impl Proposal { + /// Generate a proposal from a confirmed pain point. + pub fn from_pain_point(pain: &PainPoint) -> Self { + let title = format!("解决: {}", pain.summary); + let description = format!( + "检测到用户在「{}」方面遇到反复困难(出现{}次,置信度{:.0}%)。\n\ + 证据:{}", + pain.category, + pain.occurrence_count, + pain.confidence * 100.0, + pain.evidence + .iter() + .map(|e| e.user_said.as_str()) + .collect::>() + .join(";") + ); + + let steps = Self::generate_steps(pain); + + Self { + id: Uuid::new_v4().to_string(), + pain_point_id: pain.id.clone(), + title, + description, + steps, + status: ProposalStatus::Pending, + evidence_chain: pain.evidence.clone(), + confidence_at_creation: pain.confidence, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + /// Generate actionable steps based on pain category and severity. + fn generate_steps(pain: &PainPoint) -> Vec { + let mut steps = Vec::new(); + + match pain.category.as_str() { + "logistics" => { + steps.push(ProposalStep { + index: 1, + action: "梳理流程".into(), + detail: "整理当前物流/包装流程,标注易出错环节".into(), + skill_hint: Some("researcher".into()), + }); + steps.push(ProposalStep { + index: 2, + action: "制定检查清单".into(), + detail: "为易出错环节创建标准检查清单".into(), + skill_hint: Some("collector".into()), + }); + if pain.severity == PainSeverity::High { + steps.push(ProposalStep { + index: 3, + action: "建立预警机制".into(), + detail: "设置自动检查,提前拦截常见错误".into(), + skill_hint: Some("researcher".into()), + }); + } + } + "compliance" => { + steps.push(ProposalStep { + index: 1, + action: "法规梳理".into(), + detail: "汇总当前适用的法规要求".into(), + skill_hint: Some("researcher".into()), + }); + steps.push(ProposalStep { + index: 2, + action: "差距分析".into(), + detail: "对比当前实践与法规要求的差距".into(), + skill_hint: Some("researcher".into()), + }); + } + "customer" => { + steps.push(ProposalStep { + index: 1, + action: "问题分类".into(), + detail: "将客户投诉按类型和频率分类".into(), + skill_hint: Some("collector".into()), + }); + steps.push(ProposalStep { + index: 2, + action: "改进方案".into(), + detail: "针对高频问题提出改进建议".into(), + skill_hint: Some("researcher".into()), + }); + } + "pricing" => { + steps.push(ProposalStep { + index: 1, + action: "成本分析".into(), + detail: "分析当前报价与成本的匹配度".into(), + skill_hint: Some("researcher".into()), + }); + } + "technology" => { + steps.push(ProposalStep { + index: 1, + action: "问题诊断".into(), + detail: "记录系统问题的具体表现和触发条件".into(), + skill_hint: Some("researcher".into()), + }); + steps.push(ProposalStep { + index: 2, + action: "替代方案".into(), + detail: "评估是否有更好的工具或流程替代".into(), + skill_hint: Some("researcher".into()), + }); + } + _ => { + steps.push(ProposalStep { + index: 1, + action: "深入了解".into(), + detail: format!("深入了解「{}」问题的具体细节", pain.summary), + skill_hint: None, + }); + } + } + + steps + } +} + +// --------------------------------------------------------------------------- +// SolutionGenerator — manages proposals lifecycle +// --------------------------------------------------------------------------- + +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Manages proposal generation from confirmed pain points. +pub struct SolutionGenerator { + proposals: Arc>>, +} + +impl SolutionGenerator { + pub fn new() -> Self { + Self { + proposals: Arc::new(RwLock::new(Vec::new())), + } + } + + /// Generate a proposal for a high-confidence pain point. + pub async fn generate_solution(&self, pain: &PainPoint) -> Proposal { + let proposal = Proposal::from_pain_point(pain); + let mut proposals = self.proposals.write().await; + proposals.push(proposal.clone()); + proposal + } + + /// List all proposals for a given agent's pain points. + pub async fn list_proposals(&self, pain_point_ids: &[String]) -> Vec { + let proposals = self.proposals.read().await; + proposals + .iter() + .filter(|p| pain_point_ids.contains(&p.pain_point_id)) + .cloned() + .collect() + } + + /// Update the status of a proposal. + pub async fn update_status(&self, proposal_id: &str, status: ProposalStatus) -> Option { + let mut proposals = self.proposals.write().await; + if let Some(p) = proposals.iter_mut().find(|p| p.id == proposal_id) { + p.status = status; + p.updated_at = Utc::now(); + Some(p.clone()) + } else { + None + } + } +} + +impl Default for SolutionGenerator { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::intelligence::pain_aggregator::PainStatus; + + #[test] + fn test_proposal_from_pain_point() { + let pain = PainPoint::new( + "agent-1", + "user-1", + "出口包装不合格被退回", + "logistics", + PainSeverity::High, + "又退了", + "recurring issue", + ); + let proposal = Proposal::from_pain_point(&pain); + + assert!(!proposal.id.is_empty()); + assert_eq!(proposal.pain_point_id, pain.id); + assert!(proposal.title.contains("出口包装")); + assert!(!proposal.steps.is_empty()); + assert_eq!(proposal.status, ProposalStatus::Pending); + assert_eq!(proposal.evidence_chain.len(), 1); + } + + #[test] + fn test_logistics_generates_checklist() { + let pain = PainPoint::new( + "agent-1", + "user-1", + "包装问题", + "logistics", + PainSeverity::Medium, + "test", + "test", + ); + let proposal = Proposal::from_pain_point(&pain); + assert!(proposal.steps.len() >= 2); + assert!(proposal.steps[0].skill_hint.is_some()); + } + + #[test] + fn test_high_severity_gets_extra_step() { + let pain_low = PainPoint::new( + "agent-1", + "user-1", + "test", + "logistics", + PainSeverity::Low, + "test", + "test", + ); + let pain_high = PainPoint::new( + "agent-1", + "user-1", + "test", + "logistics", + PainSeverity::High, + "test", + "test", + ); + + let p_low = Proposal::from_pain_point(&pain_low); + let p_high = Proposal::from_pain_point(&pain_high); + assert!(p_high.steps.len() > p_low.steps.len()); + } + + #[test] + fn test_proposal_serialization() { + let pain = PainPoint::new( + "agent-1", + "user-1", + "test", + "general", + PainSeverity::Low, + "test", + "test", + ); + let proposal = Proposal::from_pain_point(&pain); + let json = serde_json::to_string(&proposal).unwrap(); + let decoded: Proposal = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.id, proposal.id); + assert_eq!(decoded.status, ProposalStatus::Pending); + } + + #[tokio::test] + async fn test_solution_generator_lifecycle() { + let gen = SolutionGenerator::new(); + + let pain = PainPoint::new( + "agent-1", + "user-1", + "test", + "general", + PainSeverity::Medium, + "test", + "test", + ); + let proposal = gen.generate_solution(&pain).await; + assert_eq!(proposal.status, ProposalStatus::Pending); + + let proposals = gen.list_proposals(&[pain.id.clone()]).await; + assert_eq!(proposals.len(), 1); + + let updated = gen.update_status(&proposal.id, ProposalStatus::Accepted).await; + assert!(updated.is_some()); + assert_eq!(updated.unwrap().status, ProposalStatus::Accepted); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a861c45..9cd287b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -355,6 +355,12 @@ pub fn run() { kernel_commands::mcp::mcp_stop_service, kernel_commands::mcp::mcp_list_services, kernel_commands::mcp::mcp_call_tool, + // Butler commands + intelligence::pain_aggregator::butler_list_pain_points, + intelligence::pain_aggregator::butler_record_pain_point, + intelligence::pain_aggregator::butler_generate_solution, + intelligence::pain_aggregator::butler_list_proposals, + intelligence::pain_aggregator::butler_update_proposal_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop/src/lib/viking-client.ts b/desktop/src/lib/viking-client.ts index edbe098..858fe04 100644 --- a/desktop/src/lib/viking-client.ts +++ b/desktop/src/lib/viking-client.ts @@ -202,18 +202,8 @@ export async function extractAndStoreMemories( } /** - * Inject relevant memories into prompt for enhanced context - */ -export async function injectVikingPrompt( - agentId: string, - basePrompt: string, - userInput: string, - maxTokens?: number -): Promise { - return invoke('viking_inject_prompt', { - agentId, - basePrompt, - userInput, - maxTokens, - }); + * Get butler insights data - pain points and proposals for the but agentId } + return invoke('butler_list_pain_points', { agentId }); } + +