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

@@ -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;

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

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

View File

@@ -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");

View File

@@ -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,
});
} }