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)
This commit is contained in:
iven
2026-04-09 09:15:15 +08:00
parent 2247edc362
commit a4c89ec6f1
4 changed files with 899 additions and 11 deletions

View File

@@ -3,11 +3,16 @@
//! 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
@@ -175,10 +180,10 @@ impl Proposal {
// SolutionGenerator — manages proposals lifecycle
// ---------------------------------------------------------------------------
use std::sync::Arc;
use tokio::sync::RwLock;
/// 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>>>,
}
@@ -190,11 +195,36 @@ impl SolutionGenerator {
}
}
/// 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
}
@@ -208,12 +238,20 @@ impl SolutionGenerator {
.collect()
}
/// Update the status of a proposal.
/// 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