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:
@@ -32,6 +32,8 @@ pub mod reflection;
|
|||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
pub mod extraction_adapter;
|
pub mod extraction_adapter;
|
||||||
|
pub mod pain_aggregator;
|
||||||
|
pub mod solution_generator;
|
||||||
|
|
||||||
// Re-export main types for convenience
|
// Re-export main types for convenience
|
||||||
pub use heartbeat::HeartbeatEngineState;
|
pub use heartbeat::HeartbeatEngineState;
|
||||||
|
|||||||
570
desktop/src-tauri/src/intelligence/pain_aggregator.rs
Normal file
570
desktop/src-tauri/src/intelligence/pain_aggregator.rs
Normal file
@@ -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<PainEvidence>,
|
||||||
|
pub occurrence_count: u32,
|
||||||
|
pub first_seen: DateTime<Utc>,
|
||||||
|
pub last_seen: DateTime<Utc>,
|
||||||
|
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<Utc>,
|
||||||
|
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<RwLock<Vec<PainPoint>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PainPoint> {
|
||||||
|
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<PainPoint> {
|
||||||
|
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<PainPoint> {
|
||||||
|
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<PainPoint> {
|
||||||
|
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<String> {
|
||||||
|
let chars: Vec<char> = 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<PainAnalysisResult> {
|
||||||
|
let mut user_frustrations: Vec<String> = 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::<String>() + "…",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Arc<PainAggregator>> = OnceLock::new();
|
||||||
|
static SOLUTION_GENERATOR: OnceLock<Arc<SolutionGenerator>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn pain_store() -> Arc<PainAggregator> {
|
||||||
|
PAIN_AGGREGATOR.get_or_init(|| Arc::new(PainAggregator::new())).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn solution_store() -> Arc<SolutionGenerator> {
|
||||||
|
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<Vec<PainPoint>, 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<PainPoint, String> {
|
||||||
|
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<Vec<Proposal>, 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<Proposal, String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -355,6 +355,12 @@ pub fn run() {
|
|||||||
kernel_commands::mcp::mcp_stop_service,
|
kernel_commands::mcp::mcp_stop_service,
|
||||||
kernel_commands::mcp::mcp_list_services,
|
kernel_commands::mcp::mcp_list_services,
|
||||||
kernel_commands::mcp::mcp_call_tool,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -202,18 +202,8 @@ export async function extractAndStoreMemories(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject relevant memories into prompt for enhanced context
|
* Get butler insights data - pain points and proposals for the but agentId }
|
||||||
*/
|
return invoke<ButlerPainPoint[]>('butler_list_pain_points', { agentId });
|
||||||
export async function injectVikingPrompt(
|
|
||||||
agentId: string,
|
|
||||||
basePrompt: string,
|
|
||||||
userInput: string,
|
|
||||||
maxTokens?: number
|
|
||||||
): Promise<string> {
|
|
||||||
return invoke<string>('viking_inject_prompt', {
|
|
||||||
agentId,
|
|
||||||
basePrompt,
|
|
||||||
userInput,
|
|
||||||
maxTokens,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user