feat(hermes): implement intelligence pipeline — 4 chunks, 684 tests passing
Hermes Intelligence Pipeline closes breakpoints in ZCLAW's existing intelligence components with 4 self-contained modules: Chunk 1 — Self-improvement Loop: - ExperienceStore (zclaw-growth): FTS5+TF-IDF wrapper with scope prefix - ExperienceExtractor (desktop/intelligence): template-based extraction from successful proposals with implicit keyword detection Chunk 2 — User Modeling: - UserProfileStore (zclaw-memory): SQLite-backed structured profiles with industry/role/expertise/comm_style/recent_topics/pain_points - UserProfiler (desktop/intelligence): fact classification by category (Preference/Knowledge/Behavior) with profile summary formatting Chunk 3 — NL Cron Chinese Time Parser: - NlScheduleParser (zclaw-runtime): 6 pattern matchers for Chinese time expressions (每天/每周/工作日/间隔/每月/一次性) producing cron expressions - Period-aware hour adjustment (下午3点→15, 晚上8点→20) - Schedule intent detection + task description extraction Chunk 4 — Trajectory Compression: - TrajectoryStore (zclaw-memory): trajectory_events + compressed_trajectories - TrajectoryRecorderMiddleware (zclaw-runtime/middleware): priority 650, async non-blocking event recording via tokio::spawn - TrajectoryCompressor (desktop/intelligence): dedup, request classification, satisfaction detection, execution chain JSON Schema migrations: v2→v3 (user_profiles), v3→v4 (trajectory tables)
This commit is contained in:
394
desktop/src-tauri/src/intelligence/experience.rs
Normal file
394
desktop/src-tauri/src/intelligence/experience.rs
Normal file
@@ -0,0 +1,394 @@
|
||||
//! Experience Extractor — transforms successful proposals into reusable experiences.
|
||||
//!
|
||||
//! Closes Breakpoint 3 (successful solution → structured experience) and
|
||||
//! Breakpoint 4 (experience reuse injection) of the self-improvement loop.
|
||||
//!
|
||||
//! When a user confirms a proposal was helpful (explicitly or via implicit
|
||||
//! keyword detection), the extractor creates an [`Experience`] record and
|
||||
//! stores it through [`ExperienceStore`] for future retrieval.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
use uuid::Uuid;
|
||||
use zclaw_growth::ExperienceStore;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use super::pain_aggregator::PainPoint;
|
||||
use super::solution_generator::{Proposal, ProposalStatus};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared completion status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Completion outcome — shared across experience and trajectory modules.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CompletionStatus {
|
||||
Success,
|
||||
Partial,
|
||||
Failed,
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feedback & event types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// User feedback on a proposal's effectiveness.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProposalFeedback {
|
||||
pub proposal_id: String,
|
||||
pub outcome: CompletionStatus,
|
||||
pub user_comment: Option<String>,
|
||||
pub detected_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Event emitted when a pain point reaches high confidence.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PainConfirmedEvent {
|
||||
pub pain_point_id: String,
|
||||
pub pattern: String,
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implicit feedback detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const POSITIVE_KEYWORDS: &[&str] = &[
|
||||
"好了", "解决了", "可以了", "对了", "完美",
|
||||
"谢谢", "很好", "棒", "不错", "成功了",
|
||||
"行了", "搞定了", "OK", "ok", "搞定",
|
||||
];
|
||||
|
||||
const NEGATIVE_KEYWORDS: &[&str] = &[
|
||||
"没用", "不对", "还是不行", "错了", "差太远",
|
||||
"不好使", "不管用", "没效果", "失败", "不行",
|
||||
];
|
||||
|
||||
/// Detect implicit feedback from user messages.
|
||||
/// Returns `Some(CompletionStatus)` if a clear signal is found.
|
||||
pub fn detect_implicit_feedback(message: &str) -> Option<CompletionStatus> {
|
||||
let lower = message.to_lowercase();
|
||||
for kw in POSITIVE_KEYWORDS {
|
||||
if lower.contains(kw) {
|
||||
return Some(CompletionStatus::Success);
|
||||
}
|
||||
}
|
||||
for kw in NEGATIVE_KEYWORDS {
|
||||
if lower.contains(kw) {
|
||||
return Some(CompletionStatus::Failed);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExperienceExtractor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extracts structured experiences from successful proposals.
|
||||
///
|
||||
/// Two extraction strategies:
|
||||
/// 1. **LLM-assisted** — uses LLM to summarise context + steps (when driver available)
|
||||
/// 2. **Template fallback** — fixed-format extraction from proposal fields
|
||||
pub struct ExperienceExtractor {
|
||||
experience_store: std::sync::Arc<ExperienceStore>,
|
||||
}
|
||||
|
||||
impl ExperienceExtractor {
|
||||
pub fn new(experience_store: std::sync::Arc<ExperienceStore>) -> Self {
|
||||
Self { experience_store }
|
||||
}
|
||||
|
||||
/// Extract and store an experience from a successful proposal + pain point.
|
||||
///
|
||||
/// Uses template extraction as the default strategy. LLM-assisted extraction
|
||||
/// can be added later by wiring a driver through the constructor.
|
||||
pub async fn extract_from_proposal(
|
||||
&self,
|
||||
proposal: &Proposal,
|
||||
pain: &PainPoint,
|
||||
feedback: &ProposalFeedback,
|
||||
) -> Result<()> {
|
||||
if feedback.outcome != CompletionStatus::Success && feedback.outcome != CompletionStatus::Partial {
|
||||
debug!(
|
||||
"[ExperienceExtractor] Skipping non-success proposal {} ({:?})",
|
||||
proposal.id, feedback.outcome
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let experience = self.template_extract(proposal, pain, feedback);
|
||||
self.experience_store.store_experience(&experience).await?;
|
||||
debug!(
|
||||
"[ExperienceExtractor] Stored experience {} for pain '{}'",
|
||||
experience.id, experience.pain_pattern
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Template-based extraction — deterministic, no LLM required.
|
||||
fn template_extract(
|
||||
&self,
|
||||
proposal: &Proposal,
|
||||
pain: &PainPoint,
|
||||
feedback: &ProposalFeedback,
|
||||
) -> zclaw_growth::experience_store::Experience {
|
||||
let solution_steps: Vec<String> = proposal.steps.iter()
|
||||
.map(|s| {
|
||||
if let Some(ref hint) = s.skill_hint {
|
||||
format!("{} (工具: {})", s.detail, hint)
|
||||
} else {
|
||||
s.detail.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let context = format!(
|
||||
"痛点: {} | 类别: {} | 出现{}次 | 证据: {}",
|
||||
pain.summary,
|
||||
pain.category,
|
||||
pain.occurrence_count,
|
||||
pain.evidence.iter()
|
||||
.map(|e| e.user_said.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
);
|
||||
|
||||
let outcome = match feedback.outcome {
|
||||
CompletionStatus::Success => "成功解决",
|
||||
CompletionStatus::Partial => "部分解决",
|
||||
CompletionStatus::Failed => "未解决",
|
||||
CompletionStatus::Abandoned => "已放弃",
|
||||
};
|
||||
|
||||
zclaw_growth::experience_store::Experience::new(
|
||||
&pain.agent_id,
|
||||
&pain.summary,
|
||||
&context,
|
||||
solution_steps,
|
||||
outcome,
|
||||
)
|
||||
}
|
||||
|
||||
/// Search for relevant experiences to inject into a conversation.
|
||||
///
|
||||
/// Returns experiences whose pain pattern matches the user's current input.
|
||||
pub async fn find_relevant_experiences(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
user_input: &str,
|
||||
) -> Vec<zclaw_growth::experience_store::Experience> {
|
||||
match self.experience_store.find_by_pattern(agent_id, user_input).await {
|
||||
Ok(experiences) => {
|
||||
if !experiences.is_empty() {
|
||||
// Increment reuse count for found experiences (fire-and-forget)
|
||||
for exp in &experiences {
|
||||
let store = self.experience_store.clone();
|
||||
let exp_clone = exp.clone();
|
||||
tokio::spawn(async move {
|
||||
store.increment_reuse(&exp_clone).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
experiences
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[ExperienceExtractor] find_relevant failed: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format experiences for system prompt injection.
|
||||
/// Returns a concise block capped at ~200 Chinese characters.
|
||||
pub fn format_for_injection(
|
||||
experiences: &[zclaw_growth::experience_store::Experience],
|
||||
) -> String {
|
||||
if experiences.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut total_chars = 0;
|
||||
let max_chars = 200;
|
||||
|
||||
for exp in experiences {
|
||||
if total_chars >= max_chars {
|
||||
break;
|
||||
}
|
||||
let step_summary = exp.solution_steps.first()
|
||||
.map(|s| truncate(s, 40))
|
||||
.unwrap_or_default();
|
||||
let line = format!(
|
||||
"[过往经验] 类似「{}」做过:{},结果是{}",
|
||||
truncate(&exp.pain_pattern, 30),
|
||||
step_summary,
|
||||
exp.outcome
|
||||
);
|
||||
total_chars += line.chars().count();
|
||||
parts.push(line);
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!("\n\n--- 过往经验参考 ---\n{}", parts.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
s.to_string()
|
||||
} else {
|
||||
s.chars().take(max_chars).collect::<String>() + "…"
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::intelligence::pain_aggregator::PainSeverity;
|
||||
|
||||
fn sample_pain() -> PainPoint {
|
||||
PainPoint::new(
|
||||
"agent-1",
|
||||
"user-1",
|
||||
"出口包装不合格",
|
||||
"logistics",
|
||||
PainSeverity::High,
|
||||
"又被退了",
|
||||
"recurring packaging issue",
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_proposal(pain: &PainPoint) -> Proposal {
|
||||
Proposal::from_pain_point(pain)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_positive_feedback() {
|
||||
assert_eq!(
|
||||
detect_implicit_feedback("好了,这下解决了"),
|
||||
Some(CompletionStatus::Success)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_implicit_feedback("谢谢,完美"),
|
||||
Some(CompletionStatus::Success)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_negative_feedback() {
|
||||
assert_eq!(
|
||||
detect_implicit_feedback("还是不行"),
|
||||
Some(CompletionStatus::Failed)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_implicit_feedback("没用啊"),
|
||||
Some(CompletionStatus::Failed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_feedback() {
|
||||
assert_eq!(detect_implicit_feedback("今天天气怎么样"), None);
|
||||
assert_eq!(detect_implicit_feedback("帮我查一下"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_extract() {
|
||||
let viking = std::sync::Arc::new(zclaw_growth::VikingAdapter::in_memory());
|
||||
let store = std::sync::Arc::new(ExperienceStore::new(viking));
|
||||
let extractor = ExperienceExtractor::new(store);
|
||||
|
||||
let pain = sample_pain();
|
||||
let proposal = sample_proposal(&pain);
|
||||
let feedback = ProposalFeedback {
|
||||
proposal_id: proposal.id.clone(),
|
||||
outcome: CompletionStatus::Success,
|
||||
user_comment: Some("好了".into()),
|
||||
detected_at: Utc::now(),
|
||||
};
|
||||
|
||||
let exp = extractor.template_extract(&proposal, &pain, &feedback);
|
||||
assert!(!exp.id.is_empty());
|
||||
assert_eq!(exp.agent_id, "agent-1");
|
||||
assert!(!exp.solution_steps.is_empty());
|
||||
assert_eq!(exp.outcome, "成功解决");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_for_injection_empty() {
|
||||
assert!(ExperienceExtractor::format_for_injection(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_for_injection_with_data() {
|
||||
let exp = zclaw_growth::experience_store::Experience::new(
|
||||
"agent-1",
|
||||
"出口包装问题",
|
||||
"包装被退回",
|
||||
vec!["检查法规".into(), "使用合规材料".into()],
|
||||
"成功解决",
|
||||
);
|
||||
let formatted = ExperienceExtractor::format_for_injection(&[exp]);
|
||||
assert!(formatted.contains("过往经验"));
|
||||
assert!(formatted.contains("出口包装问题"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_stores_experience() {
|
||||
let viking = std::sync::Arc::new(zclaw_growth::VikingAdapter::in_memory());
|
||||
let store = std::sync::Arc::new(ExperienceStore::new(viking));
|
||||
let extractor = ExperienceExtractor::new(store.clone());
|
||||
|
||||
let pain = sample_pain();
|
||||
let proposal = sample_proposal(&pain);
|
||||
let feedback = ProposalFeedback {
|
||||
proposal_id: proposal.id.clone(),
|
||||
outcome: CompletionStatus::Success,
|
||||
user_comment: Some("好了".into()),
|
||||
detected_at: Utc::now(),
|
||||
};
|
||||
|
||||
extractor.extract_from_proposal(&proposal, &pain, &feedback).await.unwrap();
|
||||
|
||||
let found = store.find_by_agent("agent-1").await.unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_skips_failed_feedback() {
|
||||
let viking = std::sync::Arc::new(zclaw_growth::VikingAdapter::in_memory());
|
||||
let store = std::sync::Arc::new(ExperienceStore::new(viking));
|
||||
let extractor = ExperienceExtractor::new(store.clone());
|
||||
|
||||
let pain = sample_pain();
|
||||
let proposal = sample_proposal(&pain);
|
||||
let feedback = ProposalFeedback {
|
||||
proposal_id: proposal.id.clone(),
|
||||
outcome: CompletionStatus::Failed,
|
||||
user_comment: Some("没用".into()),
|
||||
detected_at: Utc::now(),
|
||||
};
|
||||
|
||||
extractor.extract_from_proposal(&proposal, &pain, &feedback).await.unwrap();
|
||||
|
||||
let found = store.find_by_agent("agent-1").await.unwrap();
|
||||
assert!(found.is_empty(), "Should not store experience for failed feedback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
assert_eq!(truncate("hello", 10), "hello");
|
||||
assert_eq!(truncate("这是一个很长的字符串用于测试截断", 10).chars().count(), 11); // 10 + …
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user