Files
zclaw_openfang/desktop/src-tauri/src/intelligence/experience.rs
iven 4b15ead8e7 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)
2026-04-09 17:47:43 +08:00

395 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 + …
}
}