feat(intelligence): add PainAggregator + SolutionGenerator (Chunk 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
PainAggregator: cross-session pain point merge with confidence scoring, rule-based frustration detection, and category classification. SolutionGenerator: transforms high-confidence pain points into proposals with concrete steps, skill hints, and lifecycle management. 5 Tauri commands registered: butler_list_pain_points, butler_record_pain_point, butler_generate_solution, butler_list_proposals, butler_update_proposal_status.
This commit is contained in:
343
desktop/src-tauri/src/intelligence/solution_generator.rs
Normal file
343
desktop/src-tauri/src/intelligence/solution_generator.rs
Normal file
@@ -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<ProposalStep>,
|
||||
pub status: ProposalStatus,
|
||||
pub evidence_chain: Vec<PainEvidence>,
|
||||
pub confidence_at_creation: f64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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::<Vec<_>>()
|
||||
.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<ProposalStep> {
|
||||
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<RwLock<Vec<Proposal>>>,
|
||||
}
|
||||
|
||||
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<Proposal> {
|
||||
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<Proposal> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user