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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user