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

@@ -8,9 +8,12 @@ use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::debug;
use uuid::Uuid;
use zclaw_types::Result;
use super::pain_storage::PainStorage;
// ---------------------------------------------------------------------------
// Data structures
// ---------------------------------------------------------------------------
@@ -119,9 +122,8 @@ pub struct PainAnalysisResult {
/// Aggregates pain points across conversations, merging similar ones
/// and escalating confidence as evidence accumulates.
///
/// TODO: Data is in-memory only (OnceLock + RwLock<Vec>). On app restart,
/// all accumulated pain points and evidence are lost. Persist to SQLite
/// (e.g. via zclaw-growth::SqliteStorage) for cross-session durability.
/// When the global `PAIN_STORAGE` is initialized (via `init_pain_storage`),
/// writes are dual: memory Vec (hot cache) + SQLite (durable).
pub struct PainAggregator {
pain_points: Arc<RwLock<Vec<PainPoint>>>,
}
@@ -133,7 +135,25 @@ impl PainAggregator {
}
}
/// Get the global pain storage, if initialized.
fn get_storage() -> Option<Arc<PainStorage>> {
PAIN_STORAGE.get().cloned()
}
/// Load all persisted pain points from storage into the in-memory cache.
/// Call this once during app startup after `init_pain_storage()`.
pub async fn load_from_storage(&self) -> Result<()> {
if let Some(ref storage) = Self::get_storage() {
let persisted = storage.load_all_pain_points().await?;
let mut points = self.pain_points.write().await;
*points = persisted;
debug!("[PainAggregator] Loaded {} pain points from storage", points.len());
}
Ok(())
}
/// Merge a new pain point with an existing similar one, or create a new entry.
/// Persists to SQLite if storage is configured.
pub async fn merge_or_create(&self, new_pain: PainPoint) -> Result<PainPoint> {
let mut points = self.pain_points.write().await;
@@ -143,7 +163,7 @@ impl PainAggregator {
&& Self::summaries_similar(&p.summary, &new_pain.summary)
});
if let Some(idx) = similar_idx {
let result = if let Some(idx) = similar_idx {
let existing = &mut points[idx];
existing.evidence.extend(new_pain.evidence);
existing.occurrence_count += 1;
@@ -155,12 +175,21 @@ impl PainAggregator {
if existing.occurrence_count >= 2 && existing.status == PainStatus::Detected {
existing.status = PainStatus::Confirmed;
}
Ok(existing.clone())
existing.clone()
} else {
let result = new_pain.clone();
points.push(new_pain);
Ok(result)
result
};
// Dual-write: persist to SQLite
if let Some(storage) = Self::get_storage() {
if let Err(e) = storage.store_pain_point(&result).await {
debug!("[PainAggregator] Failed to persist pain point: {}", e);
}
}
Ok(result)
}
/// Get all high-confidence pain points for an agent.
@@ -198,11 +227,17 @@ impl PainAggregator {
points.clone()
}
/// Update the status of a pain point.
/// Update the status of a pain point. Persists to SQLite if configured.
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;
// Dual-write
if let Some(storage) = Self::get_storage() {
if let Err(e) = storage.store_pain_point(p).await {
debug!("[PainAggregator] Failed to persist status update: {}", e);
}
}
}
Ok(())
}
@@ -348,6 +383,7 @@ use super::solution_generator::{Proposal, ProposalStatus, SolutionGenerator};
static PAIN_AGGREGATOR: OnceLock<Arc<PainAggregator>> = OnceLock::new();
static SOLUTION_GENERATOR: OnceLock<Arc<SolutionGenerator>> = OnceLock::new();
pub(crate) static PAIN_STORAGE: OnceLock<Arc<PainStorage>> = OnceLock::new();
fn pain_store() -> Arc<PainAggregator> {
PAIN_AGGREGATOR.get_or_init(|| Arc::new(PainAggregator::new())).clone()
@@ -357,6 +393,30 @@ fn solution_store() -> Arc<SolutionGenerator> {
SOLUTION_GENERATOR.get_or_init(|| Arc::new(SolutionGenerator::new())).clone()
}
/// Initialize pain point persistence with a SQLite pool.
///
/// Creates the schema, sets the global storage, and loads any previously
/// persisted data into the in-memory caches.
///
/// Should be called once during app startup, before any pain-related operations.
pub async fn init_pain_storage(pool: sqlx::SqlitePool) -> Result<()> {
let storage = Arc::new(PainStorage::new(pool));
storage.initialize_schema().await?;
// Set global storage (must succeed on first call)
PAIN_STORAGE.set(storage.clone()).map_err(|_| zclaw_types::ZclawError::StorageError("PainStorage already initialized".into()))?;
// Warm the in-memory caches from SQLite
let aggregator = pain_store();
aggregator.load_from_storage().await?;
let generator = solution_store();
generator.load_from_storage().await?;
debug!("[init_pain_storage] Pain storage initialized successfully");
Ok(())
}
/// List all pain points for an agent.
// @reserved: no frontend UI yet
#[tauri::command]