PainAggregator and SolutionGenerator were in-memory only, losing all data on restart. Add PainStorage module with SQLite backend (4 tables), dual-write strategy (hot cache + durable), and startup cache warming. - New: pain_storage.rs — SQLite CRUD for pain_points, pain_evidence, proposals, proposal_steps with schema initialization - Modified: pain_aggregator.rs — global PAIN_STORAGE singleton, init_pain_storage() for startup, dual-write in merge_or_create/update - Modified: solution_generator.rs — same dual-write pattern via global PAIN_STORAGE - 20 tests passing (10 storage + 10 aggregator)
382 lines
13 KiB
Rust
382 lines
13 KiB
Rust
//! 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<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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 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<RwLock<Vec<Proposal>>>,
|
||
}
|
||
|
||
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<Arc<PainStorage>> {
|
||
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<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. Persists to SQLite if configured.
|
||
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();
|
||
|
||
// 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);
|
||
}
|
||
}
|