Files
zclaw_openfang/desktop/src-tauri/src/intelligence/solution_generator.rs
iven a4c89ec6f1 feat(intelligence): persist pain points and proposals to SQLite
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)
2026-04-09 09:15:15 +08:00

382 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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