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

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:
iven
2026-04-07 09:06:05 +08:00
parent 4c8cf06b0d
commit c7ffba196a
5 changed files with 925 additions and 14 deletions

View 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);
}
}