//! 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 std::sync::Arc; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tracing::debug; use uuid::Uuid; use super::pain_aggregator::{PainEvidence, PainPoint, PainSeverity}; use super::pain_storage::PainStorage; // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- /// Manages proposal generation from confirmed pain points. /// /// When the global `PAIN_STORAGE` is initialized (via `init_pain_storage`), /// writes are dual: memory Vec (hot cache) + SQLite (durable). pub struct SolutionGenerator { proposals: Arc>>, } impl SolutionGenerator { pub fn new() -> Self { Self { proposals: Arc::new(RwLock::new(Vec::new())), } } /// Get the global pain storage, if initialized. fn get_storage() -> Option> { super::pain_aggregator::PAIN_STORAGE.get().cloned() } /// Load all persisted proposals from storage into the in-memory cache. pub async fn load_from_storage(&self) -> zclaw_types::Result<()> { if let Some(storage) = Self::get_storage() { let persisted = storage.load_all_proposals().await?; let mut proposals = self.proposals.write().await; *proposals = persisted; debug!("[SolutionGenerator] Loaded {} proposals from storage", proposals.len()); } Ok(()) } /// Generate a proposal for a high-confidence pain point. /// Persists to SQLite if storage is configured. 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()); // Dual-write if let Some(storage) = Self::get_storage() { if let Err(e) = storage.store_proposal(&proposal).await { debug!("[SolutionGenerator] Failed to persist proposal: {}", e); } } 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. Persists to SQLite if configured. 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(); // Dual-write if let Some(storage) = Self::get_storage() { if let Err(e) = storage.store_proposal(p).await { debug!("[SolutionGenerator] Failed to persist proposal update: {}", e); } } 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); } }