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
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:
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -68,17 +68,20 @@ impl AgentMiddleware for EvolutionMiddleware {
|
||||
&self,
|
||||
ctx: &mut MiddlewareContext,
|
||||
) -> Result<MiddlewareDecision> {
|
||||
// 先用 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<evolution-suggestion>\n\
|
||||
我注意到你经常做「{pattern}」相关的事情。\n\
|
||||
@@ -86,17 +89,16 @@ impl AgentMiddleware for EvolutionMiddleware {
|
||||
技能描述:{desc}\n\
|
||||
如果你同意,请回复 '确认保存技能'。如果你想调整,可以告诉我怎么改。\n\
|
||||
</evolution-suggestion>",
|
||||
pattern = evolution.pattern_name,
|
||||
trigger = evolution.trigger_suggestion,
|
||||
desc = evolution.description,
|
||||
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
|
||||
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 仍保留
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user