diff --git a/crates/zclaw-growth/src/evolution_engine.rs b/crates/zclaw-growth/src/evolution_engine.rs index 4fafb8e..bd9f902 100644 --- a/crates/zclaw-growth/src/evolution_engine.rs +++ b/crates/zclaw-growth/src/evolution_engine.rs @@ -56,9 +56,10 @@ impl EvolutionEngine { } /// Backward-compatible constructor - pub fn from_experience_store(_experience_store: Arc) -> Self { + /// 从 ExperienceStore 中提取共享的 VikingAdapter 实例 + pub fn from_experience_store(experience_store: Arc) -> Self { Self { - viking: Arc::new(VikingAdapter::in_memory()), + viking: experience_store.viking().clone(), feedback: FeedbackCollector::new(), config: EvolutionConfig::default(), } diff --git a/crates/zclaw-growth/src/experience_store.rs b/crates/zclaw-growth/src/experience_store.rs index 63af9d8..ca0310c 100644 --- a/crates/zclaw-growth/src/experience_store.rs +++ b/crates/zclaw-growth/src/experience_store.rs @@ -109,6 +109,11 @@ impl ExperienceStore { Self { viking } } + /// Get a reference to the underlying VikingAdapter. + pub fn viking(&self) -> &Arc { + &self.viking + } + /// Store (or overwrite) an experience. The URI is derived from /// `agent_id + pain_pattern`, ensuring one experience per pattern. pub async fn store_experience(&self, exp: &Experience) -> zclaw_types::Result<()> { diff --git a/crates/zclaw-growth/src/json_utils.rs b/crates/zclaw-growth/src/json_utils.rs index 6dc945e..4b4d55c 100644 --- a/crates/zclaw-growth/src/json_utils.rs +++ b/crates/zclaw-growth/src/json_utils.rs @@ -3,6 +3,7 @@ /// 从 LLM 返回文本中提取 JSON 块 /// 支持三种格式:```json...``` 围栏、```...``` 围栏、裸 {...} +/// 使用括号平衡算法找到第一个完整 JSON 块,避免误匹配 pub fn extract_json_block(text: &str) -> &str { // 尝试匹配 ```json ... ``` if let Some(start) = text.find("```json") { @@ -18,15 +19,57 @@ pub fn extract_json_block(text: &str) -> &str { return text[json_start..json_start + end].trim(); } } - // 尝试找 { ... } 块 - if let Some(start) = text.find('{') { - if let Some(end) = text.rfind('}') { - return &text[start..=end]; - } + // 用括号平衡算法找第一个完整 {...} 块 + if let Some(slice) = find_balanced_json(text) { + return slice; } text.trim() } +/// 使用括号平衡计数找到第一个完整的 {...} JSON 块 +/// 正确处理字符串字面量中的花括号 +fn find_balanced_json(text: &str) -> Option<&str> { + let start = text.find('{')?; + let mut depth = 0i32; + let mut in_string = false; + let mut escape_next = false; + + for (i, c) in text[start..].char_indices() { + if escape_next { + escape_next = false; + continue; + } + match c { + '\\' if in_string => escape_next = true, + '"' => in_string = !in_string, + '{' if !in_string => { + depth += 1; + } + '}' if !in_string => { + depth -= 1; + if depth == 0 { + return Some(&text[start..=start + i]); + } + } + _ => {} + } + } + None +} + +/// 从 serde_json::Value 中提取字符串数组 +/// 用于解析 LLM 返回 JSON 中的 triggers/tools 等字段 +pub fn extract_string_array(raw: &serde_json::Value, key: &str) -> Vec { + raw.get(key) + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + #[cfg(test)] mod tests { use super::*; @@ -60,4 +103,46 @@ mod tests { let text = "no json here"; assert_eq!(extract_json_block(text), "no json here"); } + + #[test] + fn test_balanced_json_skips_outer_text() { + // 第一个 { 到最后一个 } 会包含多余文本,但平衡算法只取第一个完整块 + let text = "prefix {\"a\": 1} suffix {\"b\": 2}"; + assert_eq!(extract_json_block(text), "{\"a\": 1}"); + } + + #[test] + fn test_balanced_json_handles_braces_in_strings() { + let text = r#"{"body": "function() { return x; }", "name": "test"}"#; + assert_eq!( + extract_json_block(text), + r#"{"body": "function() { return x; }", "name": "test"}"# + ); + } + + #[test] + fn test_balanced_json_handles_escaped_quotes() { + let text = r#"{"msg": "He said \"hello {world}\""}"#; + assert_eq!( + extract_json_block(text), + r#"{"msg": "He said \"hello {world}\""}"# + ); + } + + #[test] + fn test_extract_string_array() { + let raw: serde_json::Value = serde_json::from_str( + r#"{"triggers": ["报表", "日报"], "name": "test"}"#, + ) + .unwrap(); + let arr = extract_string_array(&raw, "triggers"); + assert_eq!(arr, vec!["报表", "日报"]); + } + + #[test] + fn test_extract_string_array_missing_key() { + let raw: serde_json::Value = serde_json::from_str(r#"{"name": "test"}"#).unwrap(); + let arr = extract_string_array(&raw, "triggers"); + assert!(arr.is_empty()); + } } diff --git a/crates/zclaw-growth/src/lib.rs b/crates/zclaw-growth/src/lib.rs index 8588c7b..2c91693 100644 --- a/crates/zclaw-growth/src/lib.rs +++ b/crates/zclaw-growth/src/lib.rs @@ -107,7 +107,7 @@ pub use experience_store::{Experience, ExperienceStore}; pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer}; pub use summarizer::SummaryLlmDriver; pub use experience_extractor::ExperienceExtractor; -pub use json_utils::extract_json_block; +pub use json_utils::{extract_json_block, extract_string_array}; pub use profile_updater::{ProfileFieldUpdate, UserProfileUpdater}; pub use pattern_aggregator::{AggregatedPattern, PatternAggregator}; pub use skill_generator::{SkillCandidate, SkillGenerator}; diff --git a/crates/zclaw-growth/src/skill_generator.rs b/crates/zclaw-growth/src/skill_generator.rs index f27a480..db33b45 100644 --- a/crates/zclaw-growth/src/skill_generator.rs +++ b/crates/zclaw-growth/src/skill_generator.rs @@ -67,32 +67,14 @@ impl SkillGenerator { zclaw_types::ZclawError::ConfigError(format!("Invalid skill JSON: {}", e)) })?; - let triggers: Vec = raw["triggers"] - .as_array() - .map(|a: &Vec| { - a.iter() - .filter_map(|v: &serde_json::Value| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let tools: Vec = raw["tools"] - .as_array() - .map(|a: &Vec| { - a.iter() - .filter_map(|v: &serde_json::Value| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - Ok(SkillCandidate { name: raw["name"] .as_str() .unwrap_or("未命名技能") .to_string(), description: raw["description"].as_str().unwrap_or("").to_string(), - triggers, - tools, + triggers: crate::json_utils::extract_string_array(&raw, "triggers"), + tools: crate::json_utils::extract_string_array(&raw, "tools"), body_markdown: raw["body_markdown"].as_str().unwrap_or("").to_string(), source_pattern: pattern.pain_pattern.clone(), confidence: raw["confidence"].as_f64().unwrap_or(0.5) as f32, diff --git a/crates/zclaw-growth/src/workflow_composer.rs b/crates/zclaw-growth/src/workflow_composer.rs index 35de7d1..e554756 100644 --- a/crates/zclaw-growth/src/workflow_composer.rs +++ b/crates/zclaw-growth/src/workflow_composer.rs @@ -98,19 +98,10 @@ impl WorkflowComposer { zclaw_types::ZclawError::ConfigError(format!("Invalid pipeline JSON: {}", e)) })?; - let triggers: Vec = raw["triggers"] - .as_array() - .map(|a: &Vec| { - a.iter() - .filter_map(|v: &serde_json::Value| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - Ok(PipelineCandidate { name: raw["name"].as_str().unwrap_or("未命名工作流").to_string(), description: raw["description"].as_str().unwrap_or("").to_string(), - triggers, + triggers: crate::json_utils::extract_string_array(&raw, "triggers"), yaml_content: raw["yaml_content"].as_str().unwrap_or("").to_string(), source_sessions, confidence: raw["confidence"].as_f64().unwrap_or(0.5) as f32, diff --git a/crates/zclaw-runtime/src/middleware/evolution.rs b/crates/zclaw-runtime/src/middleware/evolution.rs index 4b42b66..12c78f6 100644 --- a/crates/zclaw-runtime/src/middleware/evolution.rs +++ b/crates/zclaw-runtime/src/middleware/evolution.rs @@ -68,35 +68,37 @@ impl AgentMiddleware for EvolutionMiddleware { &self, ctx: &mut MiddlewareContext, ) -> Result { + // 先用 read lock 快速判空,避免每次对话都获取写锁 + if self.pending.read().await.is_empty() { + return Ok(MiddlewareDecision::Continue); + } + + // 只移除第一个事件,保留后续事件留待下次注入 let to_inject = { let mut pending = self.pending.write().await; if pending.is_empty() { return Ok(MiddlewareDecision::Continue); } - // 只取第一条(最近的)事件注入,避免信息过载 - // drain 已注入的事件,防止重复注入 - std::mem::take(&mut *pending) + pending.remove(0) }; - if let Some(evolution) = to_inject.into_iter().next() { - let injection = format!( - "\n\n\n\ - 我注意到你经常做「{pattern}」相关的事情。\n\ - 我可以帮你整理成一个技能,以后直接说「{trigger}」就能用了。\n\ - 技能描述:{desc}\n\ - 如果你同意,请回复 '确认保存技能'。如果你想调整,可以告诉我怎么改。\n\ - ", - pattern = evolution.pattern_name, - trigger = evolution.trigger_suggestion, - desc = evolution.description, - ); - ctx.system_prompt.push_str(&injection); + let injection = format!( + "\n\n\n\ +我注意到你经常做「{pattern}」相关的事情。\n\ +我可以帮你整理成一个技能,以后直接说「{trigger}」就能用了。\n\ +技能描述:{desc}\n\ +如果你同意,请回复 '确认保存技能'。如果你想调整,可以告诉我怎么改。\n\ +", + pattern = to_inject.pattern_name, + trigger = to_inject.trigger_suggestion, + desc = to_inject.description, + ); + ctx.system_prompt.push_str(&injection); - tracing::info!( - "[EvolutionMiddleware] Injected evolution suggestion for: {}", - evolution.pattern_name - ); - } + tracing::info!( + "[EvolutionMiddleware] Injected evolution suggestion for: {}", + to_inject.pattern_name + ); Ok(MiddlewareDecision::Continue) } @@ -135,4 +137,29 @@ mod tests { assert_eq!(mw.name(), "evolution"); assert_eq!(mw.priority(), 78); } + + #[tokio::test] + async fn test_only_first_event_injected() { + let mw = EvolutionMiddleware::new(); + mw.add_pending(PendingEvolution { + pattern_name: "事件A".to_string(), + trigger_suggestion: "触发A".to_string(), + description: "描述A".to_string(), + }) + .await; + mw.add_pending(PendingEvolution { + pattern_name: "事件B".to_string(), + trigger_suggestion: "触发B".to_string(), + description: "描述B".to_string(), + }) + .await; + + // 模拟注入:用 read 判空 + write 取第一个 + let first = { + let mut pending = mw.pending.write().await; + pending.remove(0) + }; + assert_eq!(first.pattern_name, "事件A"); + assert_eq!(mw.pending_count().await, 1); // 事件B 仍保留 + } }