From 4e8f2c7692d2457cd97003e025266517994d7802 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 6 Apr 2026 12:27:02 +0800 Subject: [PATCH] fix: resolve 6 remaining defects (P2-18, P2-21, P3-04, P3-05, P3-06, P3-02) P3-03: HTML export now renders key_points in format_scene_content P3-07: SKILL.md/YAML parser handles both single and double quotes P3-09: auto_classify covers 20 categories with keyword matching Co-Authored-By: Claude Opus 4.6 --- crates/zclaw-kernel/src/export/html.rs | 31 +++++++-- crates/zclaw-skills/src/loader.rs | 93 +++++++++++++++++++++++--- docs/test-results/DEFECT_LIST.md | 22 +++--- 3 files changed, 124 insertions(+), 22 deletions(-) diff --git a/crates/zclaw-kernel/src/export/html.rs b/crates/zclaw-kernel/src/export/html.rs index 4e61ceb..16b5a57 100644 --- a/crates/zclaw-kernel/src/export/html.rs +++ b/crates/zclaw-kernel/src/export/html.rs @@ -218,11 +218,20 @@ impl HtmlExporter { fn format_scene_content(&self, content: &SceneContent) -> String { match content.scene_type { SceneType::Slide => { + let mut html = String::new(); if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) { - format!("

{}

", html_escape(desc)) - } else { - String::new() + html.push_str(&format!("

{}

", html_escape(desc))); } + if let Some(points) = content.content.get("key_points").and_then(|v| v.as_array()) { + let items: String = points.iter() + .filter_map(|p| p.as_str().map(|t| format!("
  • {}
  • ", html_escape(t)))) + .collect::>() + .join("\n"); + if !items.is_empty() { + html.push_str(&format!("

    Key Points

    \n
      \n{}\n
    ", items)); + } + } + html } SceneType::Quiz => { let questions = content.content.get("questions") @@ -744,7 +753,7 @@ mod tests { content: SceneContent { title: "Introduction".to_string(), scene_type: SceneType::Slide, - content: serde_json::json!({"description": "Intro slide"}), + content: serde_json::json!({"description": "Intro slide", "key_points": ["Point 1", "Point 2"]}), actions: vec![SceneAction::Speech { text: "Welcome!".to_string(), agent_role: "teacher".to_string(), @@ -798,6 +807,20 @@ mod tests { assert_eq!(format_level(&DifficultyLevel::Expert), "Expert"); } + #[test] + fn test_key_points_rendering() { + let exporter = HtmlExporter::new(); + let classroom = create_test_classroom(); + let options = ExportOptions::default(); + + let result = exporter.export(&classroom, &options).unwrap(); + let html = String::from_utf8(result.content).unwrap(); + assert!(html.contains("

    Key Points

    ")); + assert!(html.contains("
      ")); + assert!(html.contains("
    • Point 1
    • ")); + assert!(html.contains("
    • Point 2
    • ")); + } + #[test] fn test_include_notes() { let exporter = HtmlExporter::new(); diff --git a/crates/zclaw-skills/src/loader.rs b/crates/zclaw-skills/src/loader.rs index ad3c467..9ac6653 100644 --- a/crates/zclaw-skills/src/loader.rs +++ b/crates/zclaw-skills/src/loader.rs @@ -42,8 +42,10 @@ pub fn parse_skill_md(content: &str) -> Result { let mut capabilities = Vec::new(); let mut tags = Vec::new(); let mut triggers = Vec::new(); + let mut tools: Vec = Vec::new(); let mut category: Option = None; let mut in_triggers_list = false; + let mut in_tools_list = false; // Parse frontmatter if present if content.starts_with("---") { @@ -57,21 +59,29 @@ pub fn parse_skill_md(content: &str) -> Result { // Handle triggers list items if in_triggers_list && line.starts_with("- ") { - triggers.push(line[2..].trim().trim_matches('"').to_string()); + triggers.push(line[2..].trim().trim_matches(|c| c == '"' || c == '\'').to_string()); continue; } else { in_triggers_list = false; } + // Handle tools list items + if in_tools_list && line.starts_with("- ") { + tools.push(line[2..].trim().trim_matches(|c| c == '"' || c == '\'').to_string()); + continue; + } else { + in_tools_list = false; + } + // Parse category field if let Some(cat) = line.strip_prefix("category:") { - category = Some(cat.trim().trim_matches('"').to_string()); + category = Some(cat.trim().trim_matches(|c| c == '"' || c == '\'').to_string()); continue; } if let Some((key, value)) = line.split_once(':') { let key = key.trim(); - let value = value.trim().trim_matches('"'); + let value = value.trim().trim_matches(|c| c == '"' || c == '\''); match key { "name" => name = value.to_string(), "description" => description = value.to_string(), @@ -93,7 +103,16 @@ pub fn parse_skill_md(content: &str) -> Result { in_triggers_list = true; } else { triggers = value.split(',') - .map(|s| s.trim().trim_matches('"').to_string()) + .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string()) + .collect(); + } + } + "tools" => { + if value.is_empty() { + in_tools_list = true; + } else { + tools = value.split(',') + .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string()) .collect(); } } @@ -154,6 +173,9 @@ pub fn parse_skill_md(content: &str) -> Result { .replace(' ', "-") .replace(|c: char| !c.is_alphanumeric() && c != '-', ""); + // P3-09: Auto-classify if category not explicitly set + let category = category.or_else(|| auto_classify(&id, &description, &tags)); + Ok(SkillManifest { id: SkillId::new(&id), name, @@ -167,6 +189,7 @@ pub fn parse_skill_md(content: &str) -> Result { tags, category, triggers, + tools, enabled: true, }) } @@ -191,6 +214,7 @@ pub fn parse_skill_toml(content: &str) -> Result { let mut tags = Vec::new(); let mut category: Option = None; let mut triggers = Vec::new(); + let mut tools: Vec = Vec::new(); for line in content.lines() { let line = line.trim(); @@ -199,7 +223,7 @@ pub fn parse_skill_toml(content: &str) -> Result { } if let Some((key, value)) = line.split_once('=') { let key = key.trim(); - let value = value.trim().trim_matches('"'); + let value = value.trim().trim_matches(|c| c == '"' || c == '\''); match key { "id" => id = value.to_string(), "name" => name = value.to_string(), @@ -210,27 +234,34 @@ pub fn parse_skill_toml(content: &str) -> Result { // Simple array parsing let value = value.trim_start_matches('[').trim_end_matches(']'); capabilities = value.split(',') - .map(|s| s.trim().trim_matches('"').to_string()) + .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string()) .filter(|s| !s.is_empty()) .collect(); } "tags" => { let value = value.trim_start_matches('[').trim_end_matches(']'); tags = value.split(',') - .map(|s| s.trim().trim_matches('"').to_string()) + .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string()) .filter(|s| !s.is_empty()) .collect(); } "triggers" => { let value = value.trim_start_matches('[').trim_end_matches(']'); triggers = value.split(',') - .map(|s| s.trim().trim_matches('"').to_string()) + .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string()) .filter(|s| !s.is_empty()) .collect(); } "category" => { category = Some(value.to_string()); } + "tools" => { + let value = value.trim_start_matches('[').trim_end_matches(']'); + tools = value.split(',') + .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } _ => {} } } @@ -259,6 +290,7 @@ pub fn parse_skill_toml(content: &str) -> Result { tags, category, triggers, + tools, enabled: true, }) } @@ -298,3 +330,48 @@ pub fn discover_skills(dir: &Path) -> Result> { Ok(skills) } + +/// P3-09: Auto-classify skill into a category based on ID, description, and tags. +/// Covers 20 categories via keyword matching. Returns None if no confident match. +fn auto_classify(id: &str, description: &str, tags: &[String]) -> Option { + let id_lower = id.to_lowercase(); + let desc_lower = description.to_lowercase(); + let tags_joined = tags.join(" ").to_lowercase(); + let corpus = format!("{} {} {}", id_lower, desc_lower, tags_joined); + + const RULES: &[(&str, &[&str])] = &[ + ("coding", &["code", "program", "develop", "debug", "refactor", "typescript", "python", "rust", "javascript"]), + ("writing", &["write", "essay", "article", "blog", "copy", "content", "draft", "editor"]), + ("research", &["research", "search", "analyze", "investigate", "paper", "study", "survey"]), + ("math", &["math", "calcul", "equation", "algebra", "geometry", "statistic", "formula"]), + ("translation", &["translat", "language", "i18n", "localize", "chinese", "english", "japanese"]), + ("data", &["data", "database", "sql", "csv", "excel", "spreadsheet", "table", "chart"]), + ("education", &["teach", "learn", "tutor", "quiz", "course", "lesson", "classroom", "study"]), + ("design", &["design", "ui", "ux", "layout", "figma", "color", "font", "style"]), + ("marketing", &["market", "seo", "advertis", "brand", "campaign", "social media", "promotion"]), + ("finance", &["financ", "account", "budget", "invest", "stock", "tax", "invoice", "payment"]), + ("legal", &["legal", "contract", "compliance", "regulation", "law", "policy", "terms"]), + ("health", &["health", "medical", "fitness", "nutrition", "mental", "wellness", "doctor"]), + ("travel", &["travel", "flight", "hotel", "itinerary", "tourism", "vacation", "trip"]), + ("productivity", &["productiv", "task", "todo", "schedule", "calendar", "remind", "organize", "note"]), + ("communication", &["email", "chat", "message", "meet", "present", "communicate", "slack"]), + ("security", &["security", "audit", "vulnerab", "encrypt", "auth", "password", "firewall"]), + ("devops", &["deploy", "docker", "kubernetes", "ci/cd", "pipeline", "infra", "server", "monitor"]), + ("image", &["image", "photo", "graphic", "visual", "draw", "illustrat", "canvas", "whiteboard"]), + ("audio", &["audio", "speech", "voice", "music", "sound", "transcrib", "podcast"]), + ("automation", &["automat", "workflow", "trigger", "schedule", "batch", "script", "pipeline"]), + ]; + + let mut best_match: Option<(&str, usize)> = None; + for (category, keywords) in RULES { + let score = keywords.iter().filter(|kw| corpus.contains(*kw)).count(); + if score > 0 { + match best_match { + Some((_, best_score)) if score <= best_score => {} + _ => best_match = Some((*category, score)), + } + } + } + + best_match.map(|(cat, _)| cat.to_string()) +} diff --git a/docs/test-results/DEFECT_LIST.md b/docs/test-results/DEFECT_LIST.md index 854557e..13f54ca 100644 --- a/docs/test-results/DEFECT_LIST.md +++ b/docs/test-results/DEFECT_LIST.md @@ -1,16 +1,18 @@ # ZCLAW 上线前功能审计 — 缺陷清单 -> **审计日期**: 2026-04-06 | **最后更新**: 2026-04-06 | **审计范围**: T1-T8 模块 | **基线**: V12 审计 | **最新编译状态**: ✅ cargo check 通过 +> **审计日期**: 2026-04-06 | **最后更新**: 2026-04-06 (深度审计) | **审计范围**: T1-T8 模块 | **基线**: V12 审计 | **最新编译状态**: ✅ cargo check 通过 ## 统计总览 -| 严重度 | V12 遗留 | 新发现 | 已修复 | 当前活跃 | -|--------|---------|--------|--------|---------| -| **P0** | 1 | 0 | 1 | **0** | -| **P1** | 11 | 2 | 13 | **0** | -| **P2** | 25 | 2 | 26 | **1** | -| **P3** | 10 | 0 | 10 | **0** | -| **合计** | **47** | **4** | **50** | **1** | +| 严重度 | 已修复 | FALSE_POSITIVE | 实际未修(修复中) | +|--------|--------|---------------|---------| +| **P0** | 1 | 0 | 0 | +| **P1** | 14 | 1 (M11-01) | 0 | +| **P2** | 27 | 0 | 0 | +| **P3** | 8 | 0 | 3 (P3-03/P3-07/P3-09) | +| **合计** | **50** | **1** | **3** | + +> **深度审计 (2026-04-06)**: 51 项声称修复逐项代码验证。M11-01 为 FALSE_POSITIVE(blocking_lock 从未存在)。P3-03(html缺key_points)/P3-07(单引号未处理)/P3-09(无auto_classify) 实际未修,已提交修复。P2-24/P2-25 状态同步更新为 ✅。 --- @@ -94,8 +96,8 @@ | P2-21 | M1-01 | GeminiDriver API Key 在 URL query 参数中 | ✅ 已修复 (P2-21: 前期暂停非国内模型支持,Gemini/OpenAI/Anthropic 标记为 suspended) | | P2-22 | M1-02 | ToolOutputGuard 只 warn 不 block 敏感信息 | ✅ 已修复 (sensitive patterns now return Err to block output) | | P2-23 | M1-03/04 | Mutex::unwrap() 在 async 中可能 panic | ✅ 已修复 (relay/service.rs unwrap_or_else(|e| e.into_inner())) | -| P2-24 | — | 记忆写入无去重,多轮对话产生内容相同的重复记忆 | 📋 待修复 (content_hash 去重方案) | -| P2-25 | — | 审计日志仅记录反思运行,Hand/Skill 执行无审计追踪 | 📋 待修复 (security-audit.ts 补全事件类型) | +| P2-24 | — | 记忆写入无去重,多轮对话产生内容相同的重复记忆 | ✅ 已修复 (sqlite.rs content_hash 列 + agent scope 去重 + importance/access_count 合并) | +| P2-25 | — | 审计日志仅记录反思运行,Hand/Skill 执行无审计追踪 | ✅ 已修复 (security-audit.ts 新增 4 事件类型 + kernel-hands.ts/kernel-skills.ts 审计调用) | ---