fix(growth): 二次审计修复 — 6项 CRITICAL/HIGH/MEDIUM 全部修复
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

CRITICAL-1/2: json_utils 花括号匹配改为括号平衡算法
  - 处理字符串字面量中的花括号和转义引号
  - 新增 5 个测试(平衡匹配、字符串内花括号、转义引号、extract_string_array)

HIGH-4: EvolutionMiddleware 只取第一个事件(remove(0)),不丢弃后续
HIGH-5: EvolutionMiddleware 先 read() 判空再 write(),减少锁竞争
HIGH-7: from_experience_store 使用传入 store 的 viking 实例(不再忽略参数)
  - ExperienceStore 新增 viking() getter

MEDIUM-9: skill_generator + workflow_composer JSON 数组解析去重
  - 新增 json_utils::extract_string_array() 共享函数
MEDIUM-14: EvolutionMiddleware 注入文本去除多余缩进空格

测试: zclaw-growth 133 tests, zclaw-runtime 87 tests, workspace 0 failures
This commit is contained in:
iven
2026-04-18 22:30:10 +08:00
parent a9ea9d8691
commit cb727fdcc7
7 changed files with 150 additions and 59 deletions

View File

@@ -56,9 +56,10 @@ impl EvolutionEngine {
}
/// Backward-compatible constructor
pub fn from_experience_store(_experience_store: Arc<ExperienceStore>) -> Self {
/// 从 ExperienceStore 中提取共享的 VikingAdapter 实例
pub fn from_experience_store(experience_store: Arc<ExperienceStore>) -> Self {
Self {
viking: Arc::new(VikingAdapter::in_memory()),
viking: experience_store.viking().clone(),
feedback: FeedbackCollector::new(),
config: EvolutionConfig::default(),
}

View File

@@ -109,6 +109,11 @@ impl ExperienceStore {
Self { viking }
}
/// Get a reference to the underlying VikingAdapter.
pub fn viking(&self) -> &Arc<VikingAdapter> {
&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<()> {

View File

@@ -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<String> {
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());
}
}

View File

@@ -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};

View File

@@ -67,32 +67,14 @@ impl SkillGenerator {
zclaw_types::ZclawError::ConfigError(format!("Invalid skill JSON: {}", e))
})?;
let triggers: Vec<String> = raw["triggers"]
.as_array()
.map(|a: &Vec<serde_json::Value>| {
a.iter()
.filter_map(|v: &serde_json::Value| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let tools: Vec<String> = raw["tools"]
.as_array()
.map(|a: &Vec<serde_json::Value>| {
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,

View File

@@ -98,19 +98,10 @@ impl WorkflowComposer {
zclaw_types::ZclawError::ConfigError(format!("Invalid pipeline JSON: {}", e))
})?;
let triggers: Vec<String> = raw["triggers"]
.as_array()
.map(|a: &Vec<serde_json::Value>| {
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,