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:
iven
2026-04-09 17:47:43 +08:00
parent 0883bb28ff
commit 4b15ead8e7
15 changed files with 4225 additions and 0 deletions

View 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 + …
}
}