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)
395 lines
13 KiB
Rust
395 lines
13 KiB
Rust
//! 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 + …
|
||
}
|
||
}
|