From 417a9fccb6d12c93634e12355fb5173c7421279a Mon Sep 17 00:00:00 2001 From: lofyer Date: Fri, 7 Aug 2026 21:01:17 +0800 Subject: [PATCH] feat: improve skill import and runtime delivery --- .../skills/competitive-positioning/SKILL.md | 56 ++ .../templates/competitive-positioning.md | 39 ++ resources/skills/customer-case-study/SKILL.md | 55 ++ .../templates/customer-case-study.md | 47 ++ resources/skills/data-summary/SKILL.md | 37 -- resources/skills/deai-writing/SKILL.md | 272 ++++++++ .../skills/deai-writing/ai_smell_dict.py | 109 ++++ resources/skills/deai-writing/deai_scan.py | 305 +++++++++ .../deai-writing/project_rules/example.py | 26 + .../deai-writing/tests/test_deai_scan.py | 107 ++++ resources/skills/document-writing/SKILL.md | 33 - resources/skills/email-assistant/SKILL.md | 35 - resources/skills/longdoc-docx/SKILL.md | 168 +++++ resources/skills/longdoc-docx/diagram_kit.py | 169 +++++ .../skills/longdoc-docx/requirements.txt | 6 + .../skills/longdoc-docx/scripts/build_docx.py | 598 ++++++++++++++++++ .../skills/longdoc-docx/scripts/verify_pdf.py | 199 ++++++ .../longdoc-docx/templates/chapter.example.md | 19 + .../templates/document.example.json | 18 + .../longdoc-docx/tests/test_build_docx.py | 227 +++++++ .../longdoc-docx/tests/test_verify_pdf.py | 76 +++ resources/skills/meeting-minutes/SKILL.md | 37 -- .../skills/presentation-outline/SKILL.md | 39 -- resources/skills/product-evidence/SKILL.md | 88 +++ .../scripts/validate_evidence.py | 431 +++++++++++++ .../templates/product-evidence.example.json | 120 ++++ .../tests/test_validate_evidence.py | 113 ++++ .../skills/product-feature-catalog/SKILL.md | 56 ++ .../templates/feature-catalog.md | 27 + resources/skills/product-marketing/SKILL.md | 200 ++++++ .../scripts/validate_route_plan.py | 422 ++++++++++++ .../templates/route-plan.example.json | 77 +++ .../tests/test_validate_route_plan.py | 157 +++++ resources/skills/product-one-pager/SKILL.md | 57 ++ .../templates/product-one-pager.md | 37 ++ .../skills/product-presentation/SKILL.md | 82 +++ .../product-presentation/requirements.txt | 1 + .../scripts/build_pptx.py | 590 +++++++++++++++++ .../templates/deck.example.json | 80 +++ .../tests/test_build_pptx.py | 147 +++++ resources/skills/project-planning/SKILL.md | 38 -- resources/skills/proofreading/SKILL.md | 35 - .../skills/requirements-analysis/SKILL.md | 39 -- resources/skills/research-synthesis/SKILL.md | 38 -- resources/skills/sales-demo-kit/SKILL.md | 58 ++ .../sales-demo-kit/templates/demo-kit.md | 49 ++ resources/skills/solution-whitepaper/SKILL.md | 62 ++ .../templates/solution-whitepaper.md | 42 ++ .../skills/spreadsheet-analysis/SKILL.md | 38 -- resources/skills/technical-proposal/SKILL.md | 74 +++ .../templates/technical-proposal.md | 71 +++ .../skills/tender-response-matrix/SKILL.md | 61 ++ .../templates/tender-response-matrix.md | 28 + .../skills/tender-technical-spec/SKILL.md | 68 ++ .../templates/tender-technical-spec.md | 32 + resources/skills/translation-polish/SKILL.md | 36 -- resources/skills/weekly-report/SKILL.md | 47 -- src/main/agent/continue-runtime.test.ts | 39 ++ src/main/agent/continue-runtime.ts | 22 +- src/main/capabilities/builtin-skills.test.ts | 79 +++ .../capabilities/capability-service.test.ts | 71 +++ src/main/capabilities/capability-service.ts | 269 ++++++-- src/main/index.ts | 5 +- 63 files changed, 6136 insertions(+), 527 deletions(-) create mode 100644 resources/skills/competitive-positioning/SKILL.md create mode 100644 resources/skills/competitive-positioning/templates/competitive-positioning.md create mode 100644 resources/skills/customer-case-study/SKILL.md create mode 100644 resources/skills/customer-case-study/templates/customer-case-study.md delete mode 100644 resources/skills/data-summary/SKILL.md create mode 100644 resources/skills/deai-writing/SKILL.md create mode 100644 resources/skills/deai-writing/ai_smell_dict.py create mode 100644 resources/skills/deai-writing/deai_scan.py create mode 100644 resources/skills/deai-writing/project_rules/example.py create mode 100644 resources/skills/deai-writing/tests/test_deai_scan.py delete mode 100644 resources/skills/document-writing/SKILL.md delete mode 100644 resources/skills/email-assistant/SKILL.md create mode 100644 resources/skills/longdoc-docx/SKILL.md create mode 100644 resources/skills/longdoc-docx/diagram_kit.py create mode 100644 resources/skills/longdoc-docx/requirements.txt create mode 100644 resources/skills/longdoc-docx/scripts/build_docx.py create mode 100644 resources/skills/longdoc-docx/scripts/verify_pdf.py create mode 100644 resources/skills/longdoc-docx/templates/chapter.example.md create mode 100644 resources/skills/longdoc-docx/templates/document.example.json create mode 100644 resources/skills/longdoc-docx/tests/test_build_docx.py create mode 100644 resources/skills/longdoc-docx/tests/test_verify_pdf.py delete mode 100644 resources/skills/meeting-minutes/SKILL.md delete mode 100644 resources/skills/presentation-outline/SKILL.md create mode 100644 resources/skills/product-evidence/SKILL.md create mode 100644 resources/skills/product-evidence/scripts/validate_evidence.py create mode 100644 resources/skills/product-evidence/templates/product-evidence.example.json create mode 100644 resources/skills/product-evidence/tests/test_validate_evidence.py create mode 100644 resources/skills/product-feature-catalog/SKILL.md create mode 100644 resources/skills/product-feature-catalog/templates/feature-catalog.md create mode 100644 resources/skills/product-marketing/SKILL.md create mode 100644 resources/skills/product-marketing/scripts/validate_route_plan.py create mode 100644 resources/skills/product-marketing/templates/route-plan.example.json create mode 100644 resources/skills/product-marketing/tests/test_validate_route_plan.py create mode 100644 resources/skills/product-one-pager/SKILL.md create mode 100644 resources/skills/product-one-pager/templates/product-one-pager.md create mode 100644 resources/skills/product-presentation/SKILL.md create mode 100644 resources/skills/product-presentation/requirements.txt create mode 100644 resources/skills/product-presentation/scripts/build_pptx.py create mode 100644 resources/skills/product-presentation/templates/deck.example.json create mode 100644 resources/skills/product-presentation/tests/test_build_pptx.py delete mode 100644 resources/skills/project-planning/SKILL.md delete mode 100644 resources/skills/proofreading/SKILL.md delete mode 100644 resources/skills/requirements-analysis/SKILL.md delete mode 100644 resources/skills/research-synthesis/SKILL.md create mode 100644 resources/skills/sales-demo-kit/SKILL.md create mode 100644 resources/skills/sales-demo-kit/templates/demo-kit.md create mode 100644 resources/skills/solution-whitepaper/SKILL.md create mode 100644 resources/skills/solution-whitepaper/templates/solution-whitepaper.md delete mode 100644 resources/skills/spreadsheet-analysis/SKILL.md create mode 100644 resources/skills/technical-proposal/SKILL.md create mode 100644 resources/skills/technical-proposal/templates/technical-proposal.md create mode 100644 resources/skills/tender-response-matrix/SKILL.md create mode 100644 resources/skills/tender-response-matrix/templates/tender-response-matrix.md create mode 100644 resources/skills/tender-technical-spec/SKILL.md create mode 100644 resources/skills/tender-technical-spec/templates/tender-technical-spec.md delete mode 100644 resources/skills/translation-polish/SKILL.md delete mode 100644 resources/skills/weekly-report/SKILL.md create mode 100644 src/main/capabilities/builtin-skills.test.ts diff --git a/resources/skills/competitive-positioning/SKILL.md b/resources/skills/competitive-positioning/SKILL.md new file mode 100644 index 0000000..0d04674 --- /dev/null +++ b/resources/skills/competitive-positioning/SKILL.md @@ -0,0 +1,56 @@ +--- +name: competitive-positioning +version: 1.0.0 +description: | + 基于公开、可定位且有日期的来源生成竞品矩阵、差异化定位和销售边界。用于市场 + 分析、产品定位和受控销售准备;不允许推断竞品“不支持”或生成无证据攻击性话术。 +allowed-tools: + - Read + - Grep + - Glob +compatibility: Markdown/JSON;需要提供或获准获取竞品公开来源 +--- + +# 竞品与定位分析 + +## 必要输入 + +- 本产品事实与证据。 +- 明确的竞品名称、版本、目标市场和比较日期。 +- 竞品官方文档、发布说明、公开价格页或经批准第三方来源。 +- 比较目的、受众和允许公开的范围。 + +## 公平比较规则 + +1. 比较同一时间、版本、部署模式和授权范围。 +2. 所有产品使用同一组维度和判定标准。 +3. 每个单元格标记: + - `verified`:来源明确支持该结论。 + - `inferred`:基于有限信息的推论,不能作为确定事实外发。 + - `unknown`:未找到可验证信息。 +4. 未公开的信息写 `unknown`,不能写“不支持”。 +5. 价格必须注明日期、地区、计费单位、版本和附加条件。 +6. 安全、合规和性能结论必须使用原始证书或测试条件。 + +## 输出 + +使用 `templates/competitive-positioning.md` 生成: + +- 比较范围和方法。 +- 竞品能力矩阵。 +- 来源台账。 +- 本产品适合赢得和不适合争夺的场景。 +- 经证据支持的差异化表述。 +- 销售问答与禁止话术。 + +## 定位原则 + +- 定位说明“对特定受众,在特定场景下为什么适合”,不是宣布全面领先。 +- 差异点必须对应用户决策标准和可验证产品事实。 +- 明确本产品限制,避免销售把定位扩张成产品承诺。 +- 竞品材料过期、版本不明或来源撤回时,相关结论立即失效。 + +## 完成标准 + +每个比较结论有来源和日期;未知与不支持严格分开;比较维度一致;没有贬损性、 +法律风险或未经批准的价格信息;定位与当前产品版本、功能状态和适用边界一致。 diff --git a/resources/skills/competitive-positioning/templates/competitive-positioning.md b/resources/skills/competitive-positioning/templates/competitive-positioning.md new file mode 100644 index 0000000..50d4026 --- /dev/null +++ b/resources/skills/competitive-positioning/templates/competitive-positioning.md @@ -0,0 +1,39 @@ +# {{产品名称}}竞品与定位分析 + +**比较日期**:{{YYYY-MM-DD}} +**比较范围**:{{市场、版本、部署和授权范围}} +**使用限制**:内部 / 受控销售 / 可公开 + +## 一、评价维度与方法 + +| 维度 | 判定标准 | 数据来源要求 | +|---|---|---| +| {{维度}} | {{统一标准}} | {{官方文档/测试报告}} | + +## 二、竞品矩阵 + +| 维度 | 本产品 | 竞品 A | 竞品 B | +|---|---|---|---| +| {{维度}} | {{结论 [verified]}} | {{结论 [verified/inferred/unknown]}} | {{结论}} | + +## 三、来源台账 + +| 来源 ID | 产品 | 标题 | URL/文档 | 版本 | 日期 | 访问日期 | 权威性 | +|---|---|---|---|---|---|---|---| +| SRC-001 | {{产品}} | {{标题}} | {{来源}} | {{版本}} | {{日期}} | {{日期}} | 官方 | + +## 四、定位 + +### 适合优先争取的场景 + +- {{目标受众 + 场景 + 可验证差异}} + +### 不适合或需谨慎的场景 + +- {{本产品限制或竞品明确优势}} + +## 五、销售问答与禁止话术 + +| 客户问题 | 有依据的回答 | 来源 | 禁止表述 | +|---|---|---|---| +| {{问题}} | {{回答}} | {{SRC/CLM ID}} | {{无依据绝对化说法}} | diff --git a/resources/skills/customer-case-study/SKILL.md b/resources/skills/customer-case-study/SKILL.md new file mode 100644 index 0000000..294d717 --- /dev/null +++ b/resources/skills/customer-case-study/SKILL.md @@ -0,0 +1,55 @@ +--- +name: customer-case-study +version: 1.0.0 +description: | + 基于客户授权、实施记录和可复核指标生成客户案例、成功故事和案例摘要。用于公开 + 宣传、销售材料或受控投标引用;没有披露授权或历史结果时不得生成可发布案例。 +allowed-tools: + - Read + - Grep + - Glob +compatibility: Markdown;建议配合 product-evidence +--- + +# 客户案例 + +## 发布前硬门禁 + +必须具备: + +- 客户名称、Logo、引语和项目范围的书面披露授权,或明确匿名化要求。 +- 实施前基线、实施后结果、测量周期、样本和统计口径。 +- 产品版本、部署范围、客户责任和第三方条件。 +- 对最终文案、数字和图片的审批责任人。 + +缺少任一项时只能输出内部案例草稿和缺口清单,不得生成“已发布”版本。 + +## 叙事结构 + +1. 客户背景,只保留获准披露的信息。 +2. 具体任务和实施前状态。 +3. 方案范围、实施过程和双方责任。 +4. 产品如何参与工作流,不夸大为单一成功原因。 +5. 结果、测量方法和限制。 +6. 客户引语,仅使用获批原文。 +7. 可复用经验和适用边界。 + +使用 `templates/customer-case-study.md` 起草。 + +## 指标规则 + +- 同时给出基线和结果,不能只给改善百分比。 +- 说明周期、样本、排除项、数据来源和计算方法。 +- 区分相关性与因果性,不把同期其他变化归功于产品。 +- 预测收益、POC 目标和真实生产结果不能混写。 +- 小样本、人工评分或模型评估必须明确说明。 + +## 匿名化 + +匿名案例仍需授权。删除或泛化名称、地点、项目编号、截图账号、内部系统名和可 +反向识别组合信息;匿名化不能改变事实、行业范围和测量口径。 + +## 完成标准 + +授权范围覆盖全部文字、数字、Logo、引语和图片;案例描述与实施记录一致;指标可 +复算;产品贡献不过度归因;限制清楚;公开版不含客户隐私、合同信息或内部路径。 diff --git a/resources/skills/customer-case-study/templates/customer-case-study.md b/resources/skills/customer-case-study/templates/customer-case-study.md new file mode 100644 index 0000000..85ae8de --- /dev/null +++ b/resources/skills/customer-case-study/templates/customer-case-study.md @@ -0,0 +1,47 @@ +# {{客户授权名称或匿名描述}}:{{案例主题}} + +> 发布状态:内部草稿 / 客户审核中 / 已批准公开 +> 授权记录:{{授权文件与范围}} +> 产品版本:{{版本}} + +## 客户背景 + +{{只写获准披露的行业、规模和业务范围。}} + +## 实施前任务与基线 + +| 指标 | 基线 | 周期与样本 | 数据来源 | +|---|---:|---|---| +| {{指标}} | {{数值}} | {{周期、样本}} | {{来源}} | + +## 方案与实施范围 + +- 产品参与:{{工作流中的具体作用}} +- 客户责任:{{数据、流程、人员或审核}} +- 第三方条件:{{依赖}} +- 非范围项:{{不属于本案例的内容}} + +## 实施过程 + +{{阶段、关键动作和变更,不写无法核验的戏剧化叙事。}} + +## 结果与测量方法 + +| 指标 | 基线 | 结果 | 变化 | 测量条件 | 证据 | +|---|---:|---:|---:|---|---| +| {{指标}} | {{值}} | {{值}} | {{值}} | {{口径}} | {{EVD-001}} | + +## 客户引语 + +> “{{仅使用获批原文}}” + +## 适用边界与经验 + +{{限制、样本边界、人工复核要求和可复用经验。}} + +## 发布审批 + +| 内容 | 授权范围 | 审批人 | 日期 | 状态 | +|---|---|---|---|---| +| 客户名称/Logo | {{范围}} | {{审批人}} | {{日期}} | {{状态}} | +| 指标与引语 | {{范围}} | {{审批人}} | {{日期}} | {{状态}} | diff --git a/resources/skills/data-summary/SKILL.md b/resources/skills/data-summary/SKILL.md deleted file mode 100644 index 3c7c2fb..0000000 --- a/resources/skills/data-summary/SKILL.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -id: data-summary -name: 数据摘要 -description: 将用户提供的数据或统计结果压缩为准确、易读的摘要,突出趋势、差异与限制。 -version: 1.0.0 -tags: - - 数据 - - 摘要 - - 汇报 ---- - -# 数据摘要 - -## 工作原则 - -- 保留原始单位、时间范围、样本范围和统计口径。 -- 不补造数值,不隐去影响解释的重要异常或限制。 -- 使用绝对值与相对变化时,清楚标注基准。 -- 避免把描述性结果升级为因果结论或普遍规律。 - -## 摘要流程 - -1. 明确摘要面向的读者和需要回答的问题。 -2. 识别总量、趋势、结构、差异和异常。 -3. 核对数字之间的关系及四舍五入口径。 -4. 按重要性筛选少量关键发现。 -5. 补充数据质量、样本和解释边界。 - -## 输出结构 - -- **一句话结论:** 最重要且有数据支持的信息 -- **关键数字:** 数值、单位、周期和对比基准 -- **主要趋势:** 方向、幅度和持续时间 -- **值得关注:** 异常、分组差异或转折点 -- **限制说明:** 缺失、偏差或不可比较之处 - -若用户未提供足够数据,先列出缺口,不以推测代替结果。 diff --git a/resources/skills/deai-writing/SKILL.md b/resources/skills/deai-writing/SKILL.md new file mode 100644 index 0000000..a02fc79 --- /dev/null +++ b/resources/skills/deai-writing/SKILL.md @@ -0,0 +1,272 @@ +--- +name: deai-writing +version: 1.1.0 +description: | + 中文正式文档「去 AI 味」审校。用于任何需要产出不露 AI 痕迹的正式中文文本: + 投标方案、技术方案、公司官网文案、研究文章、汇报材料、说明文档、商务邮件。 + 在生成或润色中文正式文档之后调用,也可在评审阶段单独调用做质量门禁。 + 提供可执行的病症词典扫描脚本,把「凭感觉找 AI 味」变成「按清单定位并改写」。 + 触发词:去 AI 味、AI 腔、AI 味、文案审校、润色中文文档、官网文案评审。 +allowed-tools: + - Read + - Grep + - Glob + - Execute +compatibility: Python 3.9+,不依赖第三方 Python 包 +--- + +# 中文正式文档「去 AI 味」审校 + +AI 味不是玄学,而是一批可枚举、可正则命中、可批量修改的固定套路。所以这件事 +能做成脚本 + 清单反复调用,不用每次靠人肉感觉。 + +## 什么时候用 + +- 刚用大模型生成或润色完一份中文正式文档,交付前。 +- 长文方案编制流程里,接在关键词核验之后、人工通读之前,作为固定质量门禁 + (见 `longdoc-docx` 技能)。 +- 官网/产品文案评审,被人挑出「像 AI 写的」但说不清哪里像。 + +## 怎么用 + +先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux +优先使用 `python3`;不要使用未经验证的 Windows `py` 或 WindowsApps +`python3.exe`。下文 `` 表示探测成功的解释器命令。 + +```bash + "/deai_scan.py" 方案.md # 单文件 + "/deai_scan.py" docs/ --ext .md # 递归目录 + "/deai_scan.py" public/ --rules my_site # 叠加项目词典 + "/deai_scan.py" 方案.md --json # 结构化输出,喂给模型改写 + "/deai_scan.py" 方案.md --fail-on-block # CI 门禁,阻断项非零则退出 1 +``` + +`` 指本 `SKILL.md` 所在目录。不要假定技能安装在固定路径,项目技能、 +个人技能和插件技能的安装位置不同。 + +输出分两级: + +- **阻断项**:命中即应改写,目标压到接近 0。 +- **复核项**:只是候选,结合页面类型、事实边界和专业语境逐条判断,**不追求 + 机械清零**。研究文章里的「闭环」如果确有定义就该留着。 + +标准迭代: + +1. 先确认文档类型、目标读者、称谓和不能改变的事实边界。 +2. 扫描源文件,将 JSON 命中清单与原文一起交给 Agent 定向改写。 +3. 逐条核对改写没有编造数字、删除限制条件或改变责任主体。 +4. 复扫,直到阻断项收敛;逐条处理复核项,不机械清零。 +5. 通读全文,检查关键词扫描无法发现的前后矛盾和主体错位。 + +扫描器只负责定位,不提供自动替换。语义改写必须由 Agent 结合上下文完成, +避免把专业术语、法定提示和事实边界误删。 + +脚本默认跳过 Markdown 代码块和 HTML 的 `", + ".html", + ) + self.assertEqual(findings, []) + + def test_matches_sentence_across_markdown_line_break(self): + findings = self.scan("这不是普通说明,\n而是固定对照模板。") + self.assertTrue( + any(finding["category"] == "对照模板" for finding in findings) + ) + self.assertEqual(findings[0]["line"], 1) + + def test_loads_custom_rule_file(self): + with tempfile.TemporaryDirectory() as tmp: + rules = Path(tmp) / "custom.py" + rules.write_text( + 'AI_SMELL = {"自定义": ["专属阻断词"]}\n' + 'REVIEW_ONLY = {"自定义复核": ["专属复核词"]}\n', + encoding="utf-8", + ) + block, review, _ = deai_scan.load_rules(str(rules)) + self.assertIn("专属阻断词", block["自定义"]) + self.assertIn("专属复核词", review["自定义复核"]) + + def test_project_rules_cannot_execute_code(self): + with tempfile.TemporaryDirectory() as tmp: + rules = Path(tmp) / "custom.py" + rules.write_text( + 'AI_SMELL = {}\nopen("/tmp/should-not-exist", "w")\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "不可执行"): + deai_scan.load_rules(str(rules)) + + def test_cli_json_and_failure_exit(self): + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "sample.md" + target.write_text("综上所述,本方案必将提供卓越的服务。", encoding="utf-8") + command = [sys.executable, str(SKILL_DIR / "deai_scan.py"), str(target), "--json"] + result = subprocess.run(command, check=True, capture_output=True, text=True) + report = json.loads(result.stdout) + self.assertGreater(report["block"], 0) + + failed = subprocess.run( + command + ["--fail-on-block"], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(failed.returncode, 1) + + def test_cli_fails_when_text_file_cannot_be_decoded(self): + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "sample.md" + target.write_bytes(b"\xff\xfe\x00") + result = subprocess.run( + [sys.executable, str(SKILL_DIR / "deai_scan.py"), str(target), "--json"], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 2) + self.assertEqual(len(json.loads(result.stdout)["errors"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/resources/skills/document-writing/SKILL.md b/resources/skills/document-writing/SKILL.md deleted file mode 100644 index d7ce64f..0000000 --- a/resources/skills/document-writing/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -id: document-writing -name: 文档写作 -description: 协助起草结构清晰、语气专业的中文办公文档,并在信息不足时明确标注待确认内容。 -version: 1.0.0 -tags: - - 写作 - - 文档 - - 办公 ---- - -# 文档写作 - -## 工作原则 - -- 先确认文档类型、目标读者、写作目的、语气和篇幅。 -- 仅依据用户提供的信息写作,不臆造事实、数据、引语或结论。 -- 信息缺失时使用“待确认”标记,并列出需要补充的问题。 -- 涉及隐私、机密或敏感信息时,提醒用户审阅并酌情脱敏。 - -## 推荐流程 - -1. 提炼核心目标与读者需要采取的行动。 -2. 设计“背景—要点—行动”或适合文体的结构。 -3. 使用简洁标题、短段落和一致术语完成初稿。 -4. 检查逻辑、事实边界、语气、格式与可读性。 -5. 输出成稿,并附简短的待确认事项。 - -## 输出要求 - -- 默认提供标题、正文和必要的小标题。 -- 重点结论前置,行动项写明负责人和时间要求(如已知)。 -- 避免空话、重复表达、夸张承诺和含混指代。 diff --git a/resources/skills/email-assistant/SKILL.md b/resources/skills/email-assistant/SKILL.md deleted file mode 100644 index 3eaacf1..0000000 --- a/resources/skills/email-assistant/SKILL.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -id: email-assistant -name: 邮件助手 -description: 协助撰写、改写和回复专业邮件,突出目的、关键信息与明确行动项。 -version: 1.0.0 -tags: - - 邮件 - - 沟通 - - 办公 ---- - -# 邮件助手 - -## 工作原则 - -- 明确收件人关系、邮件目的、期望行动、截止时间和语气。 -- 不编造姓名、职位、承诺、附件内容或已发生的沟通。 -- 对敏感信息、外部收件人和群发场景提示用户复核。 -- 避免施压、冒犯、歧义和不必要的冗长表达。 - -## 撰写流程 - -1. 用具体主题概括事项和所需行动。 -2. 开头直接说明背景与来意。 -3. 分点呈现事实、问题或请求。 -4. 明确下一步、负责人和时间(如已知)。 -5. 使用与关系和场景相符的结束语。 - -## 输出格式 - -- **主题:** 简短且可检索。 -- **正文:** 称呼、目的、要点、行动请求、结束语。 -- **待确认:** 列出缺失的收件人、日期、附件或事实。 - -回复邮件时,应区分已回答问题、尚待确认问题和新增行动项。 diff --git a/resources/skills/longdoc-docx/SKILL.md b/resources/skills/longdoc-docx/SKILL.md new file mode 100644 index 0000000..5982ecb --- /dev/null +++ b/resources/skills/longdoc-docx/SKILL.md @@ -0,0 +1,168 @@ +--- +name: longdoc-docx +version: 1.0.0 +description: | + 将多章节 Markdown 构建为排版规范的 Word 长文,并通过临时 PDF 核验排版。用于 + 投标方案、技术方案、白皮书、验收报告等包含封面、目录、表格、图片、代码块和 + 分页规则的中文正式文档。不要用于只需简单复制文本的短文档。 +allowed-tools: + - Read + - Grep + - Glob + - Execute +compatibility: Python 3.9+;DOCX 构建需 requirements.txt,PDF 核验需 LibreOffice Writer +--- + +# Markdown 长文转 Word + +以 Markdown 和图表生成脚本为唯一信源。不要手工修改生成的 DOCX/PDF,修订应回到 +源文件后重新构建,避免正文、图表、编号和交叉引用失去同步。 + +`` 指本 `SKILL.md` 所在目录,不要假定技能安装在固定路径。 + +## 首次准备 + +先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux +优先使用 `python3`。下文 `` 表示探测成功的解释器命令。 + +```bash + -m pip install -r "/requirements.txt" +cp "/templates/document.example.json" ./document.json +``` + +编辑 `document.json`,至少填写: + +- `title`、`subtitle`、`author`、`date` +- `output`,生成的 DOCX 路径 +- `chapters`,按最终顺序显式列出 Markdown 文件 +- 每章的 `page_break_before`,只在真正的一级章节前设为 `true` + +不得依赖目录排序自动拼接正文。大纲、README、评审记录等内部文件不要加入 +`chapters`。 + +## 目录约定 + +交付物与核验中间产物必须分处不同目录,避免整目录拷贝时把中间产物一并发出: + +```text +build/ # 草稿与中间产物,可随时重建 + document.json # 构建配置 + chapters/ # 正文章节,按 01- 02- 前缀命名 + 01-overview.md + 02-design.md + assets/ # 图片与图表脚本产出的 PNG + drafts/ # 大纲、评审记录、废弃稿,永不进入 chapters + check/ # 核验用 PDF、verification.json、页面 PNG +dist/ # 交付物,只存放 DOCX + document.docx +``` + +`output` 指向 `dist/`;PDF、`--json`、`--render-dir` 一律指向 `build/check/`。 +目录名可随项目调整,但交付物目录内不得出现 PDF、PNG 和核验报告。 + +分章节时另有三条约束: + +- 图片路径相对**引用它的 Markdown 文件**解析,不是相对 `document.json`。章节在 + `chapters/` 而图片在 `assets/` 时,需回退一级再进入 assets 目录。 +- 章节文件名前缀只用于人工排序,构建顺序完全由 `chapters` 数组决定。改动章节 + 顺序必须改数组,重命名文件不会生效。 +- `drafts/` 与 `chapters/` 必须分开存放。混在一起时,评审记录和废弃稿极易被 + 误加入 `chapters`,且无法通过目视区分。 + +## 标准工作流 + +### 1. 核对源文件 + +1. 固定标题层级和编号体系,再开始合并。 +2. 检查 Markdown 图片路径都相对当前 Markdown 文件所在目录可解析。 +3. 搜索残留 ASCII 流程图和重复代码块,已有正式图片时删除旧占位图。 +4. 关键设计变化后同步修改图表生成脚本。 +5. 逐条比对 `chapters` 数组与 `chapters/` 内的实际文件:数组遗漏会静默少章, + 多余路径会直接构建失败。章节数和顺序都要与目录核对一次。 + +如需脚本化绘制中文架构图,可导入 `diagram_kit.py`;先检查字体: + +```bash + "/diagram_kit.py" --check-font +``` + +### 2. 构建 DOCX + +```bash + "/scripts/build_docx.py" --config ./document.json +``` + +构建器支持标题、普通段落、粗体/斜体/行内代码、嵌套列表、表格、图片、图注、 +围栏代码块、引用块、封面、目录域和页脚页码。表格按各列内容长度分配宽度,避免 +长文本列过窄导致页数异常增长。 + +目录由 Word 域生成。首次在 Microsoft Word 或 LibreOffice Writer 中打开后需更新 +目录域,未更新时看到提示文字属于正常情况。 + +### 3. 转换 PDF(仅用于核验) + +PDF 是校验中间件,不是交付物。交付物为 DOCX;PDF 只用于第 4、5 步的乱码、 +空白页和视觉复核,核验通过后应删除,除非用户明确要求交付 PDF。 + +```bash +soffice --headless --convert-to pdf --outdir ./build/check ./dist/document.docx +``` + +如果目标路径中已有同名 PDF,先确认它是可重建产物,再由 Agent 按当前工具安全 +规则处理。不要覆盖用户手工维护的文件。 + +### 4. 程序化核验 + +```bash + "/scripts/verify_pdf.py" ./build/check/document.pdf \ + --forbid "我方" "我们" \ + --json ./build/check/verification.json \ + --render-dir ./build/check/pages +``` + +核验器检查页数、乱码替换符、禁用词和疑似空白页,并可按 300 DPI 渲染逐页 PNG。 +程序化文本抽取不能证明视觉排版正确,跨页表格尤其可能出现抽取顺序异常。 + +### 5. 人工门禁 + +- 逐页检查标题孤行、表格跨页、图片清晰度、图注和异常留白。 +- 可疑文字必须查看 300 DPI 页面图,必要时裁剪放大,不能依据缩略图判断错字。 +- 核对标题编号、图号、表号、交叉引用和正文设计是否一致。 +- 检查事实边界、责任主体和前后逻辑,关键词清零不代表内容正确。 +- 如安装了 `deai-writing` 技能,在 Markdown 源文件上完成扫描和定向改写后, + 重新走完整构建链路。 + +## 完成标准 + +只有以下条件全部满足才可交付: + +1. DOCX 可打开,标题、表格、图片和代码块数量符合源文件。 +2. 核验用 PDF 转换成功,无非预期空白页和 `\ufffd` 乱码。 +3. 禁用词与项目质量门禁通过。 +4. 300 DPI 视觉复核通过,图文、编号和交叉引用一致。 +5. 所有修改已回写 Markdown 或图表脚本,生成产物可重复构建。 +6. 交付目录只有 DOCX,核验 PDF、报告和页面 PNG 都在中间产物目录内。 + +## 文件构成 + +```text +longdoc-docx/ + SKILL.md + requirements.txt + diagram_kit.py + scripts/ + build_docx.py + verify_pdf.py + templates/ + document.example.json + chapter.example.md + tests/ + test_build_docx.py + test_verify_pdf.py +``` + +## 验证技能 + +```bash + -m unittest discover -s "/tests" -p "test_*.py" +``` diff --git a/resources/skills/longdoc-docx/diagram_kit.py b/resources/skills/longdoc-docx/diagram_kit.py new file mode 100644 index 0000000..1b6cc67 --- /dev/null +++ b/resources/skills/longdoc-docx/diagram_kit.py @@ -0,0 +1,169 @@ +"""matplotlib 架构图/流程图通用工具箱(中文可用)。 + +不用画图工具手绘,用脚本画方框和箭头:改文字就是改字符串;配色字体统一由 +常量控制;图表能进 git diff,方便 review 措辞变更。 + +用法:在你自己的 gen_diagrams.py 里 + import sys, os + sys.path.insert(0, "") + from diagram_kit import box, arrow, new_fig, save, row_layout, NAVY, RED + + def diagram_architecture(): + fig, ax = new_fig(13, 9.2) + box(ax, 0.5, 8.0, 12, 0.8, "接入层") + ... + save(fig, "diagram1-总体技术架构图.png", out_dir=OUT_DIR) + +自检字体: + python3 diagram_kit.py --check-font +""" +import os +import sys + +import matplotlib +matplotlib.use("Agg") # 无显示环境必须 +import matplotlib.pyplot as plt +import matplotlib.font_manager as fm +from matplotlib.patches import FancyBboxPatch, FancyArrowPatch + +# --------------------------------------------------------------------------- +# 中文字体:matplotlib 默认字体不含中文字形,必须显式指定字体文件 +# 按优先级探测;找不到时报错并给出安装提示,而不是静默输出方块字 +# --------------------------------------------------------------------------- + +FONT_CANDIDATES = [ + ("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"), + ("/usr/share/fonts/opentype/noto/NotoSansCJK-VF.otf.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-VF.otf.ttc"), + ("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"), + ("/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/PingFang.ttc"), + ("C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/msyhbd.ttc"), +] + +FONT_HINT = ( + "未找到中文字体,图中中文会渲染成方块。安装:\n" + " Debian/Ubuntu: apt-get install fonts-noto-cjk\n" + " RHEL/CentOS: yum install google-noto-sans-cjk-ttc-fonts\n" + "确认:fc-list | grep -i 'noto sans cjk'\n" + "也可设环境变量 CJK_FONT_REGULAR / CJK_FONT_BOLD 指向字体文件。" +) + + +def _resolve_fonts(): + reg = os.environ.get("CJK_FONT_REGULAR") + bold = os.environ.get("CJK_FONT_BOLD", reg) + if reg and os.path.exists(reg): + return reg, (bold if bold and os.path.exists(bold) else reg) + for r, b in FONT_CANDIDATES: + if os.path.exists(r): + return r, (b if os.path.exists(b) else r) + return None, None + + +FONT_PATH, FONT_PATH_BOLD = _resolve_fonts() +if FONT_PATH is None: + print("WARN: " + FONT_HINT, file=sys.stderr) + zh_font = fm.FontProperties() + zh_bold = fm.FontProperties(weight="bold") +else: + zh_font = fm.FontProperties(fname=FONT_PATH) + zh_bold = fm.FontProperties(fname=FONT_PATH_BOLD) + +# --------------------------------------------------------------------------- +# 配色:中文商务文档惯例(藏青主色 + 红色强调 + 灰阶) +# 换主题只改这几个常量,所有图一起变 +# --------------------------------------------------------------------------- + +NAVY = "#1F3864" +NAVY_LIGHT = "#DCE6F1" +RED = "#C00000" +RED_LIGHT = "#FBE4E4" +GRAY = "#595959" +GRAY_LIGHT = "#F2F2F2" +WHITE = "#FFFFFF" +TEXT = "#1a1a1a" + + +def box(ax, x, y, w, h, text, fc=WHITE, ec=NAVY, lw=1.4, fontsize=10.5, + font=None, textcolor=TEXT, + boxstyle="round,pad=0.02,rounding_size=0.06", zorder=2): + """圆角方框 + 居中文字。linespacing 保证多行文字换行后不挤在一起。""" + b = FancyBboxPatch((x, y), w, h, boxstyle=boxstyle, linewidth=lw, + edgecolor=ec, facecolor=fc, zorder=zorder) + ax.add_patch(b) + ax.text(x + w / 2, y + h / 2, text, ha="center", va="center", + fontsize=fontsize, fontproperties=font or zh_font, + color=textcolor, zorder=zorder + 1, linespacing=1.4) + return b + + +def arrow(ax, xy_from, xy_to, color=GRAY, lw=1.6, style="-|>", + connectionstyle="arc3,rad=0.0", zorder=3): + a = FancyArrowPatch(xy_from, xy_to, arrowstyle=style, mutation_scale=14, + linewidth=lw, color=color, + connectionstyle=connectionstyle, zorder=zorder) + ax.add_patch(a) + return a + + +def label(ax, x, y, text, fontsize=9.5, color=GRAY, ha="center", va="center", + font=None, zorder=4): + """箭头旁的说明文字、图内小标注。""" + return ax.text(x, y, text, ha=ha, va=va, fontsize=fontsize, + fontproperties=font or zh_font, color=color, zorder=zorder) + + +def new_fig(w, h, dpi=200): + """画布坐标系直接等于英寸尺寸,摆位时按网格心算即可。dpi=200 保证放大不糊。""" + fig, ax = plt.subplots(figsize=(w, h), dpi=dpi) + ax.set_xlim(0, w) + ax.set_ylim(0, h) + ax.axis("off") + return fig, ax + + +def save(fig, name, out_dir="."): + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, name) + fig.savefig(path, bbox_inches="tight", facecolor="white") + plt.close(fig) + print("saved:", path) + return path + + +def row_layout(n, start_x, total_w, gap=0.25): + """横向等宽切分:返回 n 个 (x, width),用于并列分支摆放。 + + for (x, w), g in zip(row_layout(len(groups), 0.5, 12.0), groups): + box(ax, x, y0, w, h, g) + """ + w = (total_w - gap * (n - 1)) / n + return [(start_x + i * (w + gap), w) for i in range(n)] + + +def col_layout(n, top_y, total_h, gap=0.2): + """纵向等高切分:返回 n 个 (y, height),自上而下。""" + h = (total_h - gap * (n - 1)) / n + return [(top_y - h - i * (h + gap), h) for i in range(n)] + + +def _check_font(): + if FONT_PATH is None: + print("中文字体:未找到\n" + FONT_HINT) + return 1 + print(f"中文字体:{FONT_PATH}") + print(f"粗体: {FONT_PATH_BOLD}") + fig, ax = new_fig(6, 2) + box(ax, 0.3, 0.5, 5.4, 1.0, "中文字体自检 CJK Font OK 123") + out = save(fig, "font_check.png", out_dir="/tmp") + print(f"已生成 {out},打开确认中文不是方块。") + return 0 + + +if __name__ == "__main__": + if "--check-font" in sys.argv: + sys.exit(_check_font()) + print(__doc__) diff --git a/resources/skills/longdoc-docx/requirements.txt b/resources/skills/longdoc-docx/requirements.txt new file mode 100644 index 0000000..4ac280e --- /dev/null +++ b/resources/skills/longdoc-docx/requirements.txt @@ -0,0 +1,6 @@ +beautifulsoup4>=4.12,<5 +Markdown>=3.5,<4 +matplotlib>=3.8,<4 +Pillow>=10,<13 +PyMuPDF>=1.24,<2 +python-docx>=1.1,<2 diff --git a/resources/skills/longdoc-docx/scripts/build_docx.py b/resources/skills/longdoc-docx/scripts/build_docx.py new file mode 100644 index 0000000..88ec4f0 --- /dev/null +++ b/resources/skills/longdoc-docx/scripts/build_docx.py @@ -0,0 +1,598 @@ +#!/usr/bin/env python3 +"""Build a styled DOCX from an explicit Markdown chapter manifest.""" + +import argparse +import json +import re +import unicodedata +from pathlib import Path + +import markdown +from bs4 import BeautifulSoup +from docx import Document +from docx.enum.section import WD_ORIENT +from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Cm, Pt, RGBColor +from PIL import Image + + +DEFAULTS = { + "body_font_zh": "宋体", + "body_font_en": "Times New Roman", + "heading_font_zh": "黑体", + "heading_color": "1F3864", + "body_size": 11.5, + "toc_depth": 3, + "max_image_width_cm": 14.66, +} +HEADING_SIZES = {1: 18, 2: 15, 3: 13, 4: 12, 5: 11.5, 6: 11.5} + + +def parse_args(): + parser = argparse.ArgumentParser(description="将多章节 Markdown 构建为 DOCX") + parser.add_argument("--config", required=True, help="document.json 路径") + return parser.parse_args() + + +def load_config(path): + config_path = Path(path).expanduser().resolve() + with config_path.open(encoding="utf-8") as handle: + config = json.load(handle) + if not isinstance(config, dict): + raise ValueError("配置根节点必须是 JSON 对象") + for key in ("title", "output"): + if not isinstance(config.get(key), str) or not config[key].strip(): + raise ValueError(f"{key} 必须是非空字符串") + if not isinstance(config.get("chapters"), list) or not config["chapters"]: + raise ValueError("chapters 必须是非空数组") + merged = {**DEFAULTS, **config} + if not isinstance(merged["toc_depth"], int) or isinstance(merged["toc_depth"], bool): + raise ValueError("toc_depth 必须是整数") + for key in ("body_size", "max_image_width_cm"): + value = merged[key] + if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0: + raise ValueError(f"{key} 必须是正数") + return config_path, merged + + +def rgb(value): + value = value.lstrip("#") + if not re.fullmatch(r"[0-9a-fA-F]{6}", value): + raise ValueError(f"颜色必须是六位十六进制值:{value!r}") + return RGBColor.from_string(value.upper()) + + +def add_font( + run, + config, + size=None, + bold=False, + italic=False, + color=None, + code=False, + en_font=None, + zh_font=None, +): + en_font = en_font or ("Consolas" if code else config["body_font_en"]) + zh_font = zh_font or ("Consolas" if code else config["body_font_zh"]) + run.font.name = en_font + run.font.size = Pt(size or config["body_size"]) + run.font.bold = bold + run.font.italic = italic + if color is not None: + run.font.color.rgb = color + rpr = run._element.get_or_add_rPr() + rfonts = rpr.find(qn("w:rFonts")) + if rfonts is None: + rfonts = OxmlElement("w:rFonts") + rpr.append(rfonts) + rfonts.set(qn("w:ascii"), en_font) + rfonts.set(qn("w:hAnsi"), en_font) + rfonts.set(qn("w:eastAsia"), zh_font) + + +def add_field(paragraph, instruction, placeholder=None): + run = paragraph.add_run() + begin = OxmlElement("w:fldChar") + begin.set(qn("w:fldCharType"), "begin") + instr = OxmlElement("w:instrText") + instr.set(qn("xml:space"), "preserve") + instr.text = instruction + separate = OxmlElement("w:fldChar") + separate.set(qn("w:fldCharType"), "separate") + end = OxmlElement("w:fldChar") + end.set(qn("w:fldCharType"), "end") + run._r.append(begin) + run._r.append(instr) + run._r.append(separate) + if placeholder: + text = OxmlElement("w:t") + text.text = placeholder + run._r.append(text) + run._r.append(end) + + +def add_shading(target, fill): + properties = ( + target._tc.get_or_add_tcPr() + if hasattr(target, "_tc") + else target._p.get_or_add_pPr() + ) + shading = OxmlElement("w:shd") + shading.set(qn("w:val"), "clear") + shading.set(qn("w:color"), "auto") + shading.set(qn("w:fill"), fill) + properties.append(shading) + + +def set_table_borders(table): + borders = OxmlElement("w:tblBorders") + for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): + element = OxmlElement(f"w:{edge}") + element.set(qn("w:val"), "single") + element.set(qn("w:sz"), "4") + element.set(qn("w:space"), "0") + element.set(qn("w:color"), "B0B0B0") + borders.append(element) + table._tbl.tblPr.append(borders) + + +def setup_document(doc, config): + section = doc.sections[0] + if config.get("orientation", "portrait") == "landscape": + section.orientation = WD_ORIENT.LANDSCAPE + section.page_width = Cm(29.7) + section.page_height = Cm(21) + else: + section.page_width = Cm(21) + section.page_height = Cm(29.7) + section.top_margin = Cm(config.get("margin_top_cm", 2.54)) + section.bottom_margin = Cm(config.get("margin_bottom_cm", 2.54)) + section.left_margin = Cm(config.get("margin_left_cm", 3.17)) + section.right_margin = Cm(config.get("margin_right_cm", 3.17)) + + normal = doc.styles["Normal"] + normal.font.name = config["body_font_en"] + normal.font.size = Pt(config["body_size"]) + normal.paragraph_format.line_spacing = config.get("line_spacing", 1.4) + normal.paragraph_format.space_after = Pt(8) + rpr = normal.element.get_or_add_rPr() + rfonts = rpr.find(qn("w:rFonts")) + if rfonts is None: + rfonts = OxmlElement("w:rFonts") + rpr.append(rfonts) + rfonts.set(qn("w:eastAsia"), config["body_font_zh"]) + + footer = section.footer.paragraphs[0] + footer.alignment = WD_ALIGN_PARAGRAPH.CENTER + add_field(footer, "PAGE") + + +def add_cover(doc, config): + if config.get("cover", True) is False: + return + landscape = config.get("orientation", "portrait") == "landscape" + for _ in range(config.get("cover_top_spacers", 3 if landscape else 6)): + doc.add_paragraph() + for text, size in ( + (config["title"], 26), + (config.get("subtitle", ""), 22), + ): + if not text: + continue + paragraph = doc.add_paragraph() + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = paragraph.add_run(text) + add_font( + run, + config, + size=size, + bold=True, + color=rgb(config["heading_color"]), + zh_font=config["heading_font_zh"], + ) + for _ in range(config.get("cover_middle_spacers", 4 if landscape else 8)): + doc.add_paragraph() + for field in ("author", "date"): + text = config.get(field, "") + if text: + paragraph = doc.add_paragraph() + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + add_font(paragraph.add_run(text), config, size=14) + doc.add_page_break() + + +def add_toc(doc, config): + depth = int(config.get("toc_depth", 3)) + if depth <= 0: + return + heading = doc.add_paragraph() + heading.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = heading.add_run(config.get("toc_title", "目 录")) + add_font( + run, + config, + size=18, + bold=True, + color=rgb(config["heading_color"]), + zh_font=config["heading_font_zh"], + ) + doc.add_paragraph() + paragraph = doc.add_paragraph() + add_field( + paragraph, + f'TOC \\o "1-{depth}" \\h \\z \\u', + "右键点击此处选择“更新域”以生成目录", + ) + doc.add_page_break() + + +def add_inline_runs(paragraph, node, config, bold=False, italic=False): + for child in node.children: + name = getattr(child, "name", None) + if name is None: + text = str(child).replace("\n", "") + if text: + add_font( + paragraph.add_run(text), + config, + bold=bold, + italic=italic, + ) + elif name in ("strong", "b"): + add_inline_runs(paragraph, child, config, bold=True, italic=italic) + elif name in ("em", "i"): + add_inline_runs(paragraph, child, config, bold=bold, italic=True) + elif name == "code": + run = paragraph.add_run(child.get_text()) + add_font( + run, + config, + size=config["body_size"] - 0.5, + bold=bold, + italic=italic, + color=RGBColor(0xA0, 0x30, 0x30), + code=True, + ) + elif name == "br": + paragraph.add_run().add_break() + else: + add_inline_runs(paragraph, child, config, bold=bold, italic=italic) + + +def add_heading(doc, level, text, config): + paragraph = doc.add_paragraph(style=f"Heading {min(level, 9)}") + paragraph.paragraph_format.keep_with_next = True + paragraph.paragraph_format.space_before = Pt(14 if level == 1 else 10) + paragraph.paragraph_format.space_after = Pt(8 if level == 1 else 6) + run = paragraph.add_run(text) + add_font( + run, + config, + size=HEADING_SIZES.get(level, 11.5), + bold=True, + color=rgb(config["heading_color"]) if level <= 2 else RGBColor(0, 0, 0), + zh_font=config["heading_font_zh"], + ) + + +def add_paragraph(doc, node, config): + paragraph = doc.add_paragraph() + add_inline_runs(paragraph, node, config) + paragraph.paragraph_format.line_spacing = config.get("line_spacing", 1.4) + paragraph.paragraph_format.space_after = Pt(8) + + +def add_list(doc, node, config, level=0): + ordered = node.name == "ol" + style = "List Number" if ordered else "List Bullet" + for item in node.find_all("li", recursive=False): + paragraph = doc.add_paragraph(style=style) + paragraph.paragraph_format.left_indent = Cm(0.5 + level * 0.6) + paragraph.paragraph_format.space_after = Pt(4) + for child in item.children: + if getattr(child, "name", None) in ("ul", "ol"): + continue + if getattr(child, "name", None) is None: + text = str(child).replace("\n", "") + if text: + add_font(paragraph.add_run(text), config) + else: + add_inline_runs(paragraph, child, config) + for nested in item.find_all(["ul", "ol"], recursive=False): + add_list(doc, nested, config, level + 1) + + +def display_width(text): + return sum(2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 for char in text) + + +def em_width(text): + total = 0.0 + for char in text: + if unicodedata.east_asian_width(char) in ("W", "F"): + total += 1.0 + elif char.isupper() or char.isdigit(): + total += 0.62 + else: + total += 0.5 + return total + + +def compute_col_widths(rows, ncols, content_width_cm, body_size_pt=10.5): + lengths = [1] * ncols + longest_word = [1] * ncols + for row in rows: + for index, cell in enumerate(row.find_all(["th", "td"], recursive=False)): + if index < ncols: + text = cell.get_text(" ", strip=True) + lengths[index] = max(lengths[index], min(display_width(text), 160)) + longest_word[index] = max( + longest_word[index], + max((em_width(word) for word in text.split()), default=1.0), + ) + em_cm = body_size_pt / 28.35 + padding_cm = 0.4 + floors = [ + min(em_cm * word + padding_cm, content_width_cm / ncols) + for word in longest_word + ] + maximum = max(max(floors), content_width_cm * 0.55) + widths = [None] * ncols + remaining = content_width_cm + pending = set(range(ncols)) + while pending: + weight = sum(lengths[i] for i in pending) + clamped = False + for index in sorted(pending): + share = remaining * lengths[index] / weight + floor = floors[index] + bound = floor if share < floor else (maximum if share > maximum else None) + if bound is not None: + widths[index] = bound + remaining -= bound + pending.discard(index) + clamped = True + break + if not clamped: + for index in pending: + widths[index] = remaining * lengths[index] / weight + break + total = sum(widths) + if total > content_width_cm: + widths = [width * content_width_cm / total for width in widths] + return widths + + +def set_col_widths(table, widths): + table.autofit = False + grid = table._tbl.find(qn("w:tblGrid")) + if grid is None: + grid = OxmlElement("w:tblGrid") + table._tbl.insert(0, grid) + else: + for child in list(grid): + grid.remove(child) + for width in widths: + column = OxmlElement("w:gridCol") + column.set(qn("w:w"), str(int(Cm(width).twips))) + grid.append(column) + for row in table.rows: + cells = row.cells + for index, width in enumerate(widths): + if index < len(cells): + cells[index].width = Cm(width) + + +def add_table(doc, node, config): + rows = node.find_all("tr") + if not rows: + return + ncols = max(len(row.find_all(["th", "td"], recursive=False)) for row in rows) + table = doc.add_table(rows=len(rows), cols=ncols) + table.alignment = WD_TABLE_ALIGNMENT.CENTER + section = doc.sections[-1] + content_width = ( + section.page_width.cm - section.left_margin.cm - section.right_margin.cm + ) + table_size = float(config.get("table_size", config["body_size"])) + widths = compute_col_widths(rows, ncols, content_width, table_size) + set_table_borders(table) + for row_index, (row_node, table_row) in enumerate(zip(rows, table.rows)): + cell_nodes = row_node.find_all(["th", "td"], recursive=False) + table_cells = table_row.cells + for column_index, cell_node in enumerate(cell_nodes): + if column_index >= len(table_cells): + break + cell = table_cells[column_index] + cell.text = "" + cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER + paragraph = cell.paragraphs[0] + is_header = row_index == 0 + add_inline_runs(paragraph, cell_node, config, bold=is_header) + for run in paragraph.runs: + add_font( + run, + config, + size=table_size, + bold=is_header or bool(run.font.bold), + italic=bool(run.font.italic), + color=RGBColor(255, 255, 255) if is_header else None, + ) + if is_header: + add_shading(cell, config["heading_color"].lstrip("#")) + elif row_index % 2 == 0: + add_shading(cell, "F2F2F2") + set_col_widths(table, widths) + doc.add_paragraph().paragraph_format.space_after = Pt(4) + + +def add_code_block(doc, text, config): + paragraph = doc.add_paragraph() + paragraph.paragraph_format.left_indent = Cm(0.5) + paragraph.paragraph_format.space_before = Pt(4) + paragraph.paragraph_format.space_after = Pt(10) + lines = text.rstrip("\n").split("\n") + for index, line in enumerate(lines): + run = paragraph.add_run(line or " ") + add_font( + run, + config, + size=9.5, + color=RGBColor(0x33, 0x33, 0x33), + code=True, + ) + if index < len(lines) - 1: + run.add_break() + add_shading(paragraph, "F5F5F5") + + +def add_blockquote(doc, node, config): + blocks = [ + child.get_text(" ", strip=True) + for child in node.find_all("p", recursive=False) + ] + if not blocks: + blocks = [node.get_text(" ", strip=True)] + blocks = [text for text in blocks if text] + for index, text in enumerate(blocks): + paragraph = doc.add_paragraph() + paragraph.paragraph_format.left_indent = Cm(0.8) + paragraph.paragraph_format.space_after = Pt( + 10 if index == len(blocks) - 1 else 4 + ) + run = paragraph.add_run(text) + add_font( + run, config, size=10.5, italic=True, color=RGBColor(0x40, 0x40, 0x40) + ) + add_shading(paragraph, "F7F7F7") + + +def add_image(doc, src, base_dir, config): + image_path = (base_dir / src).resolve() + if not image_path.is_file(): + raise FileNotFoundError(f"图片不存在:{image_path}") + with Image.open(image_path) as image: + width_px = image.width + dpi = image.info.get("dpi", (150, 150))[0] or 150 + natural_width_cm = width_px / dpi * 2.54 + width_cm = min(natural_width_cm, float(config["max_image_width_cm"])) + paragraph = doc.add_paragraph() + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + paragraph.add_run().add_picture(str(image_path), width=Cm(width_cm)) + paragraph.paragraph_format.space_before = Pt(6) + paragraph.paragraph_format.space_after = Pt(2) + + +def add_caption(doc, text, config): + paragraph = doc.add_paragraph() + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = paragraph.add_run(text) + add_font(run, config, size=10, italic=True, color=RGBColor(0x40, 0x40, 0x40)) + paragraph.paragraph_format.space_after = Pt(12) + + +def render_markdown(doc, chapter_path, config): + text = chapter_path.read_text(encoding="utf-8") + html = markdown.markdown(text, extensions=["tables", "fenced_code"]) + soup = BeautifulSoup(html, "html.parser") + previous_was_image = False + + for node in soup.find_all(recursive=False): + name = node.name + if name in ("h1", "h2", "h3", "h4", "h5", "h6"): + add_heading(doc, int(name[1]), node.get_text(" ", strip=True), config) + elif name == "p": + image = node.find("img") + if image is not None: + if node.get_text(strip=True): + raise ValueError( + f"图片必须独占 Markdown 段落:{chapter_path}" + ) + add_image(doc, image.get("src", ""), chapter_path.parent, config) + previous_was_image = True + continue + text_value = node.get_text() + emphasis = node.find("em") + if ( + previous_was_image + and emphasis is not None + and node.get_text(strip=True) == emphasis.get_text(strip=True) + ): + add_caption(doc, emphasis.get_text(" ", strip=True), config) + else: + if text_value.strip(): + add_paragraph(doc, node, config) + elif name in ("ul", "ol"): + add_list(doc, node, config) + elif name == "table": + add_table(doc, node, config) + elif name == "blockquote": + add_blockquote(doc, node, config) + elif name == "pre": + add_code_block(doc, node.get_text(), config) + elif name == "hr": + paragraph = doc.add_paragraph("─" * 40) + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + else: + if node.get_text(strip=True): + add_paragraph(doc, node, config) + previous_was_image = False + + +def chapter_entries(config, base_dir): + entries = [] + for raw in config["chapters"]: + if isinstance(raw, str): + raw = {"path": raw} + if ( + not isinstance(raw, dict) + or not isinstance(raw.get("path"), str) + or not raw["path"].strip() + ): + raise ValueError("chapters 的每一项必须是路径字符串或包含 path 的对象") + if "page_break_before" in raw and not isinstance( + raw["page_break_before"], bool + ): + raise ValueError("page_break_before 必须是布尔值") + path = (base_dir / raw["path"]).resolve() + if not path.is_file(): + raise FileNotFoundError(f"章节不存在:{path}") + entries.append((path, bool(raw.get("page_break_before", False)))) + return entries + + +def build(config_path, config): + base_dir = config_path.parent + chapters = chapter_entries(config, base_dir) + output = (base_dir / config["output"]).resolve() + if output.suffix.lower() != ".docx": + raise ValueError("output 必须使用 .docx 扩展名") + protected_paths = {config_path, *(chapter for chapter, _ in chapters)} + if output in protected_paths: + raise ValueError("output 不能覆盖配置文件或 Markdown 源文件") + output.parent.mkdir(parents=True, exist_ok=True) + + doc = Document() + setup_document(doc, config) + add_cover(doc, config) + add_toc(doc, config) + for index, (chapter, page_break_before) in enumerate(chapters): + if page_break_before and index > 0: + doc.add_page_break() + render_markdown(doc, chapter, config) + doc.save(output) + return output + + +def main(): + config_path, config = load_config(parse_args().config) + output = build(config_path, config) + print(f"saved: {output}") + + +if __name__ == "__main__": + main() diff --git a/resources/skills/longdoc-docx/scripts/verify_pdf.py b/resources/skills/longdoc-docx/scripts/verify_pdf.py new file mode 100644 index 0000000..5181121 --- /dev/null +++ b/resources/skills/longdoc-docx/scripts/verify_pdf.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Verify PDF text gates, blank pages, and optionally render page previews.""" + +import argparse +import json +import sys +from pathlib import Path + +import fitz + + +def parse_args(): + parser = argparse.ArgumentParser(description="核验长文 PDF 产物") + parser.add_argument("pdf", help="待核验 PDF") + parser.add_argument("--forbid", nargs="*", default=[], help="禁用关键词") + parser.add_argument( + "--allow-blank-page", + action="append", + type=int, + default=[], + help="允许为空白的页码,可重复指定", + ) + parser.add_argument( + "--min-text-chars", + type=int, + default=30, + help="无图片页面低于该文本长度时视为疑似空白", + ) + parser.add_argument("--json", dest="json_path", help="JSON 报告输出路径") + parser.add_argument("--render-dir", help="逐页 PNG 输出目录") + parser.add_argument("--dpi", type=int, default=300, help="页面渲染 DPI") + parser.add_argument( + "--no-fail", + action="store_true", + help="发现乱码、禁用词或非豁免空白页时仍返回 0", + ) + return parser.parse_args() + + +def inspect_document( + document, + path, + forbidden=(), + min_text_chars=30, + allowed_blank_pages=(), +): + allowed = set(allowed_blank_pages) + terms = [term for term in dict.fromkeys(forbidden) if term] + forbidden_hits = {term: {"count": 0, "pages": []} for term in terms} + pages = [] + replacement_characters = 0 + suspicious_blank_pages = [] + for index, page in enumerate(document): + page_number = index + 1 + text = page.get_text().strip() + image_count = len(page.get_images(full=True)) + pages.append( + { + "page": page_number, + "text_chars": len(text), + "images": image_count, + } + ) + replacement_characters += text.count("\ufffd") + if ( + len(text) < min_text_chars + and image_count == 0 + and page_number not in allowed + ): + suspicious_blank_pages.append(page_number) + for term in terms: + count = text.count(term) + if count: + forbidden_hits[term]["count"] += count + forbidden_hits[term]["pages"].append(page_number) + + return { + "file": str(path), + "page_count": len(document), + "replacement_characters": replacement_characters, + "forbidden": { + term: result + for term, result in forbidden_hits.items() + if result["count"] + }, + "suspicious_blank_pages": suspicious_blank_pages, + "allowed_blank_pages": sorted(allowed), + "pages": pages, + } + + +def inspect_pdf(pdf_path, forbidden=(), min_text_chars=30, allowed_blank_pages=()): + path = Path(pdf_path).expanduser().resolve() + if not path.is_file(): + raise FileNotFoundError(f"PDF 不存在:{path}") + document = fitz.open(path) + try: + return inspect_document( + document, + path, + forbidden=forbidden, + min_text_chars=min_text_chars, + allowed_blank_pages=allowed_blank_pages, + ) + finally: + document.close() + + +def render_document(document, output_dir, dpi=300): + if dpi < 72: + raise ValueError("dpi 不能低于 72") + output = Path(output_dir).expanduser().resolve() + output.mkdir(parents=True, exist_ok=True) + existing = sorted(output.glob("page-*.png")) + if existing: + raise FileExistsError( + f"渲染目录已有页面图,请改用空目录:{output}" + ) + scale = dpi / 72 + matrix = fitz.Matrix(scale, scale) + digits = max(3, len(str(len(document)))) + rendered = [] + for index, page in enumerate(document): + target = output / f"page-{index + 1:0{digits}d}.png" + page.get_pixmap(matrix=matrix, alpha=False).save(target) + rendered.append(str(target)) + return rendered + + +def render_pages(pdf_path, output_dir, dpi=300): + document = fitz.open(Path(pdf_path).expanduser().resolve()) + try: + return render_document(document, output_dir, dpi=dpi) + finally: + document.close() + + +def has_failures(report): + return bool( + report["replacement_characters"] + or report["forbidden"] + or report["suspicious_blank_pages"] + ) + + +def main(): + args = parse_args() + pdf_path = Path(args.pdf).expanduser().resolve() + if not pdf_path.is_file(): + raise FileNotFoundError(f"PDF 不存在:{pdf_path}") + json_path = ( + Path(args.json_path).expanduser().resolve() + if args.json_path + else None + ) + render_dir = ( + Path(args.render_dir).expanduser().resolve() + if args.render_dir + else None + ) + if json_path == pdf_path: + raise ValueError("JSON 报告路径不能覆盖输入 PDF") + if render_dir == pdf_path: + raise ValueError("渲染目录不能与输入 PDF 同路径") + + document = fitz.open(pdf_path) + try: + report = inspect_document( + document, + pdf_path, + forbidden=args.forbid, + min_text_chars=args.min_text_chars, + allowed_blank_pages=args.allow_blank_page, + ) + if render_dir: + report["rendered_pages"] = render_document( + document, + render_dir, + args.dpi, + ) + finally: + document.close() + + output = json.dumps(report, ensure_ascii=False, indent=2) + if json_path: + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(output + "\n", encoding="utf-8") + print(f"report: {json_path}") + else: + print(output) + + if has_failures(report) and not args.no_fail: + print("PDF 核验失败:存在乱码、禁用词或疑似空白页。", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/skills/longdoc-docx/templates/chapter.example.md b/resources/skills/longdoc-docx/templates/chapter.example.md new file mode 100644 index 0000000..f066494 --- /dev/null +++ b/resources/skills/longdoc-docx/templates/chapter.example.md @@ -0,0 +1,19 @@ +# 一、章节标题 + +本章正文使用 Markdown 编写。图片路径相对于当前 Markdown 文件所在目录。 + +## 1. 二级标题 + +支持**粗体**、*斜体*、`行内代码`、列表和表格。 + +| 项目 | 说明 | +|---|---| +| 示例 | 表格会按内容长度分配列宽 | + +![示例架构图](assets/example.png) + +*图 1 示例架构图* + +```python +print("围栏代码块会保留缩进和换行") +``` diff --git a/resources/skills/longdoc-docx/templates/document.example.json b/resources/skills/longdoc-docx/templates/document.example.json new file mode 100644 index 0000000..cfd8b7e --- /dev/null +++ b/resources/skills/longdoc-docx/templates/document.example.json @@ -0,0 +1,18 @@ +{ + "title": "项目名称", + "subtitle": "技术方案", + "author": "编制单位:____________________", + "date": "编制日期:____________________", + "output": "../dist/document.docx", + "toc_depth": 3, + "chapters": [ + { + "path": "chapters/01-overview.md", + "page_break_before": false + }, + { + "path": "chapters/02-design.md", + "page_break_before": true + } + ] +} diff --git a/resources/skills/longdoc-docx/tests/test_build_docx.py b/resources/skills/longdoc-docx/tests/test_build_docx.py new file mode 100644 index 0000000..0be6e7d --- /dev/null +++ b/resources/skills/longdoc-docx/tests/test_build_docx.py @@ -0,0 +1,227 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +from docx import Document +from PIL import Image + + +SKILL_DIR = Path(__file__).resolve().parents[1] +SCRIPT_PATH = SKILL_DIR / "scripts" / "build_docx.py" +SPEC = importlib.util.spec_from_file_location("build_docx", SCRIPT_PATH) +build_docx = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(build_docx) + + +class BuildDocxTests(unittest.TestCase): + def write_config(self, root, config): + path = root / "document.json" + path.write_text(json.dumps(config, ensure_ascii=False), encoding="utf-8") + return path + + def test_builds_supported_markdown_elements(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + image_path = root / "diagram.png" + Image.new("RGB", (400, 200), "white").save(image_path, dpi=(200, 200)) + chapter = root / "chapter.md" + chapter.write_text( + "# 一、概述\n\n" + "正文包含**粗体**、*斜体*和`代码`。\n\n" + "- 列表一\n- 列表二\n\n" + "| 项目 | 详细说明 |\n|---|---|\n| A | 一段较长的内容 |\n\n" + "![架构图](diagram.png)\n\n" + "*图 1 架构图*\n\n" + "```python\nprint('ok')\n```\n\n" + "> 引用说明\n", + encoding="utf-8", + ) + config_path = self.write_config( + root, + { + "title": "测试文档", + "subtitle": "构建验证", + "output": "out/test.docx", + "toc_depth": 0, + "chapters": [{"path": "chapter.md"}], + }, + ) + loaded_path, config = build_docx.load_config(config_path) + output = build_docx.build(loaded_path, config) + + self.assertTrue(output.is_file()) + document = Document(output) + text = "\n".join(paragraph.text for paragraph in document.paragraphs) + self.assertIn("一、概述", text) + self.assertIn("图 1 架构图", text) + self.assertIn("print('ok')", text) + self.assertEqual(len(document.tables), 1) + images = [ + rel + for rel in document.part.rels.values() + if "image" in rel.reltype + ] + self.assertEqual(len(images), 1) + + def test_landscape_widens_tables(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + header = "| " + " | ".join(f"列{i}" for i in range(11)) + " |" + divider = "|" + "---|" * 11 + row = "| " + " | ".join(f"值{i}" for i in range(11)) + " |" + (root / "wide.md").write_text( + f"# 宽表\n\n{header}\n{divider}\n{row}\n", encoding="utf-8" + ) + widths = {} + for mode in ("portrait", "landscape"): + config_path = self.write_config( + root, + { + "title": "宽表测试", + "output": f"out/{mode}.docx", + "toc_depth": 0, + "orientation": mode, + "chapters": [{"path": "wide.md"}], + }, + ) + loaded, config = build_docx.load_config(config_path) + document = Document(build_docx.build(loaded, config)) + widths[mode] = sum( + cell.width.cm for cell in document.tables[0].rows[0].cells + ) + self.assertGreater(widths["landscape"], widths["portrait"] + 5) + + def test_wide_table_fits_longest_word_in_every_column(self): + from bs4 import BeautifulSoup + + headers = [ + "需求编号", "需求出处", "需求原文", "类别", "强制/评分", "响应状态", + "实现说明", "偏离说明", "证据编号", "方案章节", "验证方法", + ] + body = [ + "REQ-001", "示例技术要求 3.1.1", + "系统应提供与 OpenAI 接口兼容的统一调用入口。", "功能", "强制", + "compliant", "网关提供模型列表、对话补全和向量化四类接口,统一鉴权", + "无", "FEAT-001", "三.1、四.1", "依次调用四类接口并核对返回结构", + ] + head = "".join(f"{h}" for h in headers) + cells = "".join(f"{c}" for c in body) + rows = BeautifulSoup( + f"{head}{cells}
", "html.parser" + ).find_all("tr") + widths = build_docx.compute_col_widths(rows, 11, 25.7, 9.5) + + self.assertAlmostEqual(sum(widths), 25.7, places=3) + em_cm = 9.5 / 28.35 + for index, (header, cell) in enumerate(zip(headers, body)): + longest = max( + build_docx.em_width(word) + for text in (header, cell) + for word in text.split() + ) + self.assertGreaterEqual( + widths[index] + 1e-6, + min(em_cm * longest, 25.7 / 11), + f"column {index} ({header}) truncates its longest word", + ) + self.assertGreater(widths[6], widths[3]) + + def test_blockquote_keeps_paragraph_breaks(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "quote.md").write_text( + "# 引用\n\n> 第一段说明。\n>\n> 第二段说明。\n", encoding="utf-8" + ) + config_path = self.write_config( + root, + { + "title": "引用测试", + "output": "out/quote.docx", + "toc_depth": 0, + "chapters": [{"path": "quote.md"}], + }, + ) + loaded, config = build_docx.load_config(config_path) + document = Document(build_docx.build(loaded, config)) + texts = [p.text for p in document.paragraphs] + self.assertIn("第一段说明。", texts) + self.assertIn("第二段说明。", texts) + + def test_only_forced_chapter_boundary_adds_page_break(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "one.md").write_text("# 第一章\n", encoding="utf-8") + (root / "two.md").write_text("# 第二章\n", encoding="utf-8") + config_path = self.write_config( + root, + { + "title": "分页测试", + "cover": False, + "toc_depth": 0, + "output": "test.docx", + "chapters": [ + {"path": "one.md", "page_break_before": False}, + {"path": "two.md", "page_break_before": True}, + ], + }, + ) + loaded_path, config = build_docx.load_config(config_path) + output = build_docx.build(loaded_path, config) + document = Document(output) + self.assertEqual(document._element.xml.count('w:type="page"'), 1) + + def test_rejects_missing_chapters(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config_path = self.write_config( + root, + {"title": "无章节", "output": "test.docx", "chapters": []}, + ) + with self.assertRaisesRegex(ValueError, "chapters"): + build_docx.load_config(config_path) + + def test_rejects_non_boolean_page_break(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "chapter.md").write_text("# 章节\n", encoding="utf-8") + config_path = self.write_config( + root, + { + "title": "错误分页配置", + "output": "test.docx", + "chapters": [ + {"path": "chapter.md", "page_break_before": "false"} + ], + }, + ) + loaded_path, config = build_docx.load_config(config_path) + with self.assertRaisesRegex(ValueError, "page_break_before"): + build_docx.build(loaded_path, config) + + def test_rejects_mixed_text_and_image_paragraph(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + Image.new("RGB", (100, 50), "white").save(root / "diagram.png") + (root / "chapter.md").write_text( + "说明文字 ![架构图](diagram.png)\n", + encoding="utf-8", + ) + config_path = self.write_config( + root, + { + "title": "图片格式测试", + "cover": False, + "toc_depth": 0, + "output": "test.docx", + "chapters": ["chapter.md"], + }, + ) + loaded_path, config = build_docx.load_config(config_path) + with self.assertRaisesRegex(ValueError, "图片必须独占"): + build_docx.build(loaded_path, config) + + +if __name__ == "__main__": + unittest.main() diff --git a/resources/skills/longdoc-docx/tests/test_verify_pdf.py b/resources/skills/longdoc-docx/tests/test_verify_pdf.py new file mode 100644 index 0000000..b3cb0a6 --- /dev/null +++ b/resources/skills/longdoc-docx/tests/test_verify_pdf.py @@ -0,0 +1,76 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + +import fitz + + +SKILL_DIR = Path(__file__).resolve().parents[1] +SCRIPT_PATH = SKILL_DIR / "scripts" / "verify_pdf.py" +SPEC = importlib.util.spec_from_file_location("verify_pdf", SCRIPT_PATH) +verify_pdf = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(verify_pdf) + + +class VerifyPdfTests(unittest.TestCase): + def create_pdf(self, path): + document = fitz.open() + text_page = document.new_page() + text_page.insert_text( + (72, 72), + "This page contains enough verification text and a forbidden term.", + ) + document.new_page() + document.save(path) + document.close() + + def test_reports_forbidden_terms_and_blank_pages(self): + with tempfile.TemporaryDirectory() as tmp: + pdf = Path(tmp) / "sample.pdf" + self.create_pdf(pdf) + report = verify_pdf.inspect_pdf( + pdf, + forbidden=["forbidden"], + min_text_chars=30, + ) + self.assertEqual(report["page_count"], 2) + self.assertEqual(report["forbidden"]["forbidden"]["pages"], [1]) + self.assertEqual(report["suspicious_blank_pages"], [2]) + self.assertTrue(verify_pdf.has_failures(report)) + + def test_allows_known_blank_page(self): + with tempfile.TemporaryDirectory() as tmp: + pdf = Path(tmp) / "sample.pdf" + self.create_pdf(pdf) + report = verify_pdf.inspect_pdf( + pdf, + min_text_chars=30, + allowed_blank_pages=[2], + ) + self.assertEqual(report["suspicious_blank_pages"], []) + self.assertFalse(verify_pdf.has_failures(report)) + + def test_renders_pages(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pdf = root / "sample.pdf" + self.create_pdf(pdf) + rendered = verify_pdf.render_pages(pdf, root / "pages", dpi=72) + self.assertEqual(len(rendered), 2) + self.assertTrue(all(Path(path).is_file() for path in rendered)) + + def test_rejects_render_directory_with_old_pages(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pdf = root / "sample.pdf" + pages = root / "pages" + pages.mkdir() + (pages / "page-999.png").write_bytes(b"old") + self.create_pdf(pdf) + with self.assertRaisesRegex(FileExistsError, "空目录"): + verify_pdf.render_pages(pdf, pages, dpi=72) + + +if __name__ == "__main__": + unittest.main() diff --git a/resources/skills/meeting-minutes/SKILL.md b/resources/skills/meeting-minutes/SKILL.md deleted file mode 100644 index 0f1ed8a..0000000 --- a/resources/skills/meeting-minutes/SKILL.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -id: meeting-minutes -name: 会议纪要 -description: 将用户提供的会议记录整理为客观、可追踪的纪要,明确结论、分歧与行动项。 -version: 1.0.0 -tags: - - 会议 - - 纪要 - - 协作 ---- - -# 会议纪要 - -## 工作原则 - -- 忠实整理原始记录,不推测未明确表达的决定或责任。 -- 区分讨论内容、正式决策、待确认事项和行动项。 -- 保留关键分歧及其依据,避免将建议误写为结论。 -- 对姓名、日期、数字和专有名词进行一致性检查。 - -## 整理流程 - -1. 确认会议主题、时间、参会人和目标。 -2. 按议题归纳背景、讨论要点与结论。 -3. 提取每项行动的负责人、截止时间和交付物。 -4. 汇总未决问题、风险和后续会议需求。 -5. 标记原始记录中含糊或相互冲突的信息。 - -## 输出模板 - -- **会议信息:** 主题、时间、参会人 -- **会议目标:** 本次会议要解决的问题 -- **议题与结论:** 按议题分组 -- **行动项:** 事项、负责人、截止时间、状态 -- **待确认事项:** 缺失信息或未决问题 - -未提供的信息统一标注为“待确认”,不得自行补全。 diff --git a/resources/skills/presentation-outline/SKILL.md b/resources/skills/presentation-outline/SKILL.md deleted file mode 100644 index 2fa750f..0000000 --- a/resources/skills/presentation-outline/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -id: presentation-outline -name: 演示大纲 -description: 根据目标与受众设计逻辑清晰的演示文稿大纲,明确每页核心信息与叙事衔接。 -version: 1.0.0 -tags: - - 演示 - - 大纲 - - 表达 ---- - -# 演示大纲 - -## 工作原则 - -- 先明确演示目的、受众、场合、时长和期望行动。 -- 每页聚焦一个核心信息,标题应直接表达结论。 -- 事实、数据与案例仅来自用户材料;缺少依据时标注待补充。 -- 控制信息密度,避免用大段文字代替口头讲解。 - -## 设计流程 - -1. 用一句话定义演示的核心主张。 -2. 选择适合目标的叙事结构,如“问题—分析—方案—行动”。 -3. 为每页写结论式标题、关键要点和建议视觉形式。 -4. 检查页面间逻辑、证据充分性和时间分配。 -5. 以明确总结和下一步行动收尾。 - -## 输出格式 - -按页输出: - -- **页码与标题:** 结论式标题 -- **页面目的:** 该页要让受众理解什么 -- **关键内容:** 不超过五个要点 -- **视觉建议:** 图表、流程、时间线或重点数字 -- **讲述提示:** 与前后页面的衔接 - -另附开场、总结和待补充材料清单。 diff --git a/resources/skills/product-evidence/SKILL.md b/resources/skills/product-evidence/SKILL.md new file mode 100644 index 0000000..5174fe1 --- /dev/null +++ b/resources/skills/product-evidence/SKILL.md @@ -0,0 +1,88 @@ +--- +name: product-evidence +version: 1.0.0 +description: | + 建立和维护产品市场材料的事实与证据清单,统一产品版本、功能状态、技术参数、 + 术语、适用边界和可公开主张。用于开始任何产品介绍、投标参数、PPT、技术方案、 + 白皮书或案例材料之前,也用于跨产物一致性核验。 +allowed-tools: + - Read + - Grep + - Glob + - Execute +compatibility: Python 3.9+,校验脚本不依赖第三方包 +--- + +# 产品事实与证据 + +所有产品市场产物都必须从同一份 `product-evidence.json` 取事实。缺少依据时标记 +待核验,不允许由 Agent 补造参数、客户结果、认证、兼容性或竞争结论。 + +`` 指本 `SKILL.md` 所在目录。 + +## 建立清单 + +```bash +cp "/templates/product-evidence.example.json" ./product-evidence.json +``` + +逐项填写: + +- `product`:产品名称、版本、类别、定位、成熟度和目标读者。 +- `terminology`:统一术语、定义和禁用旧称。 +- `features`:功能 ID、用户动作、结果、状态、版本范围和证据。 +- `parameters`:参数值、单位、测试条件、适用版本、公开级别和证据。 +- `claims`:允许对外使用的事实、目标或比较主张及适用产物。 +- `use_cases`:角色、问题、工作流、人工复核点和已有结果。 +- `differentiators`:比较对象、比较范围和支持证据。 +- `limitations`:部署条件、依赖、适用边界和必要人工复核。 +- `evidence`:来源、定位、核验日期、责任人和公开级别。 +- `prohibited_claims`:不得出现在任何材料中的绝对化或未经批准表述。 + +## 事实分级 + +- `released`:当前版本已提供,必须有可定位证据。 +- `beta`:可试用但存在范围限制,正文必须同时说明限制。 +- `planned`:仅可用将来时或规划表述,不得写成现有能力。 +- `deprecated`:不得作为当前卖点。 + +证据公开级别: + +- `public`:可进入公开网站、彩页和公开演示。 +- `restricted`:只在授权客户或受控投标材料中使用。 +- `internal`:只用于内部判断,不能原样写入外发产物。 + +## 校验 + +先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux +优先使用 `python3`。下文 `` 表示探测成功的解释器命令。 + +```bash + "/scripts/validate_evidence.py" ./product-evidence.json + "/scripts/validate_evidence.py" ./product-evidence.json --json + "/scripts/validate_evidence.py" ./product-evidence.json \ + --strict --channel public +``` + +结构错误、重复 ID、失效引用、无证据的已批准事实主张、非法状态或疑似密钥参数 +必须阻断。缺证据、占位符和待核验项在普通模式下告警,在 `--strict` 下阻断。 + +`--channel` 按目标渠道核对公开级别:已批准且带 `allowed_outputs` 的主张,其 +引用证据的 `disclosure` 不得低于渠道要求。对外产物必须以 `--strict` 加目标 +渠道运行通过后才能进入下游技能。 + +## 给下游技能的输入 + +调用任何产品产物技能时,同时提供: + +1. 已通过校验的 `product-evidence.json`。 +2. 目标受众、使用场景、发布渠道和保密级别。 +3. 本次产物允许引用的证据范围。 +4. 截止日期、页数或篇幅、格式和品牌要求。 + +下游产物中的每个数字、兼容性、认证、客户效果和比较结论都应能追溯到清单 ID。 + +## 完成标准 + +清单结构校验通过;公开级别与使用渠道匹配;所有现有功能、参数和批准主张有 +证据;规划能力、限制和人工复核要求没有被省略;不含密钥、客户隐私或私有地址。 diff --git a/resources/skills/product-evidence/scripts/validate_evidence.py b/resources/skills/product-evidence/scripts/validate_evidence.py new file mode 100644 index 0000000..26b45f8 --- /dev/null +++ b/resources/skills/product-evidence/scripts/validate_evidence.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Validate the shared product evidence manifest used by marketing skills.""" + +import argparse +import datetime as dt +import json +import re +import sys +from pathlib import Path + + +COLLECTIONS = ( + "features", + "parameters", + "claims", + "use_cases", + "differentiators", + "limitations", + "evidence", +) +REQUIRED_FIELDS = { + "features": ("id", "name", "summary", "status"), + "parameters": ("id", "name", "value", "conditions", "disclosure"), + "claims": ("id", "text", "type", "status"), + "use_cases": ("id", "name", "audience", "problem", "workflow", "outcome"), + "differentiators": ("id", "statement", "comparison_scope"), + "limitations": ("id", "text", "applies_to"), + "evidence": ( + "id", + "type", + "title", + "source", + "locator", + "verified_on", + "owner", + "disclosure", + ), +} +ALLOWED = { + "product.maturity": {"released", "beta", "planned", "deprecated"}, + "features.status": {"released", "beta", "planned", "deprecated"}, + "claims.type": {"fact", "goal", "comparison"}, + "claims.status": {"approved", "draft", "rejected"}, + "disclosure": {"public", "restricted", "internal"}, +} +OUTPUT_TYPES = { + "feature-catalog", + "technical-spec", + "presentation", + "technical-proposal", + "one-pager", + "whitepaper", + "tender-response", + "sales-demo", + "case-study", + "competitive-positioning", +} +DISCLOSURE_RANK = {"internal": 0, "restricted": 1, "public": 2} +PLACEHOLDERS = re.compile( + r"(?i)(?:\bTBD\b|\bTODO\b|待补充|待确认|placeholder|changeme)" +) +SECRET_QUERY = re.compile(r"(?i)(?:token|api[_-]?key|secret|password)=") + + +def issue(path, message): + return {"path": path, "message": message} + + +def is_nonempty_string(value): + return isinstance(value, str) and bool(value.strip()) + + +def validate_record_shape(collection, index, record, errors): + path = f"{collection}[{index}]" + if not isinstance(record, dict): + errors.append(issue(path, "必须是对象")) + return False + for field in REQUIRED_FIELDS[collection]: + value = record.get(field) + if field in ("applies_to",): + if not isinstance(value, list) or not value: + errors.append(issue(f"{path}.{field}", "必须是非空数组")) + elif not is_nonempty_string(value): + errors.append(issue(f"{path}.{field}", "必须是非空字符串")) + return True + + +def evidence_disclosure_map(records): + return { + record.get("id"): record.get("disclosure") + for record in records["evidence"] + if isinstance(record, dict) + } + + +def validate_disclosure_chain(records, errors): + """A record must not be more public than the evidence backing it.""" + evidence_disclosure = evidence_disclosure_map(records) + for collection in ("parameters", "claims"): + for index, record in enumerate(records[collection]): + if not isinstance(record, dict): + continue + own = record.get("disclosure") + if own not in DISCLOSURE_RANK: + continue + for ref in record.get("evidence_ids", []) or []: + backing = evidence_disclosure.get(ref) + if backing not in DISCLOSURE_RANK: + continue + if DISCLOSURE_RANK[backing] < DISCLOSURE_RANK[own]: + errors.append( + issue( + f"{collection}[{index}].disclosure", + f"标记为 {own},但证据 {ref} 仅为 {backing}", + ) + ) + + +def validate_channel(records, channel, errors): + """Reject claims cleared for delivery whose evidence is too restricted.""" + target = DISCLOSURE_RANK[channel] + evidence_disclosure = evidence_disclosure_map(records) + for index, claim in enumerate(records["claims"]): + if not isinstance(claim, dict) or claim.get("status") != "approved": + continue + if not claim.get("allowed_outputs"): + continue + for ref in claim.get("evidence_ids", []) or []: + disclosure = evidence_disclosure.get(ref) + if disclosure not in DISCLOSURE_RANK: + continue + if DISCLOSURE_RANK[disclosure] < target: + errors.append( + issue( + f"claims[{index}].evidence_ids", + f"证据 {ref} 为 {disclosure},不足以支撑 {channel} 渠道", + ) + ) + + +def validate_manifest(data, channel=None): + errors = [] + warnings = [] + if not isinstance(data, dict): + return [issue("$", "根节点必须是对象")], warnings + if data.get("schema_version") != 1: + errors.append(issue("schema_version", "当前仅支持整数 1")) + + product = data.get("product") + if not isinstance(product, dict): + errors.append(issue("product", "必须是对象")) + product = {} + for field in ("name", "version", "category", "summary", "maturity"): + if not is_nonempty_string(product.get(field)): + errors.append(issue(f"product.{field}", "必须是非空字符串")) + if product.get("maturity") not in ALLOWED["product.maturity"]: + errors.append( + issue( + "product.maturity", + f"必须是 {sorted(ALLOWED['product.maturity'])} 之一", + ) + ) + + records = {} + all_ids = {} + for collection in COLLECTIONS: + values = data.get(collection, []) + if not isinstance(values, list): + errors.append(issue(collection, "必须是数组")) + values = [] + records[collection] = values + for index, record in enumerate(values): + if not validate_record_shape(collection, index, record, errors): + continue + record_id = record.get("id") + if is_nonempty_string(record_id): + if record_id in all_ids: + errors.append( + issue( + f"{collection}[{index}].id", + f"ID 与 {all_ids[record_id]} 重复:{record_id}", + ) + ) + else: + all_ids[record_id] = f"{collection}[{index}]" + + evidence_ids = { + record.get("id") + for record in records["evidence"] + if isinstance(record, dict) and is_nonempty_string(record.get("id")) + } + feature_ids = { + record.get("id") + for record in records["features"] + if isinstance(record, dict) and is_nonempty_string(record.get("id")) + } + use_case_ids = { + record.get("id") + for record in records["use_cases"] + if isinstance(record, dict) and is_nonempty_string(record.get("id")) + } + + for collection in COLLECTIONS: + for index, record in enumerate(records[collection]): + if not isinstance(record, dict): + continue + path = f"{collection}[{index}]" + refs = record.get("evidence_ids", []) + if refs is not None and not isinstance(refs, list): + errors.append(issue(f"{path}.evidence_ids", "必须是数组")) + refs = [] + for ref in refs or []: + if ref not in evidence_ids: + errors.append( + issue(f"{path}.evidence_ids", f"引用不存在:{ref}") + ) + for ref in record.get("use_case_ids", []) or []: + if ref not in use_case_ids: + errors.append( + issue(f"{path}.use_case_ids", f"引用不存在:{ref}") + ) + + for index, limitation in enumerate(records["limitations"]): + if not isinstance(limitation, dict): + continue + for ref in limitation.get("applies_to", []) or []: + if ref not in feature_ids and ref not in all_ids: + errors.append( + issue( + f"limitations[{index}].applies_to", + f"引用不存在:{ref}", + ) + ) + + for index, feature in enumerate(records["features"]): + if not isinstance(feature, dict): + continue + status = feature.get("status") + if status not in ALLOWED["features.status"]: + errors.append( + issue( + f"features[{index}].status", + f"必须是 {sorted(ALLOWED['features.status'])} 之一", + ) + ) + if status in {"released", "beta"} and not feature.get("evidence_ids"): + warnings.append( + issue( + f"features[{index}].evidence_ids", + "已发布或 beta 功能缺少证据", + ) + ) + + for index, parameter in enumerate(records["parameters"]): + if not isinstance(parameter, dict): + continue + disclosure = parameter.get("disclosure") + if disclosure not in ALLOWED["disclosure"]: + errors.append( + issue( + f"parameters[{index}].disclosure", + f"必须是 {sorted(ALLOWED['disclosure'])} 之一", + ) + ) + if not parameter.get("evidence_ids"): + warnings.append( + issue( + f"parameters[{index}].evidence_ids", + "技术参数缺少证据", + ) + ) + + for index, claim in enumerate(records["claims"]): + if not isinstance(claim, dict): + continue + if claim.get("type") not in ALLOWED["claims.type"]: + errors.append( + issue( + f"claims[{index}].type", + f"必须是 {sorted(ALLOWED['claims.type'])} 之一", + ) + ) + if claim.get("status") not in ALLOWED["claims.status"]: + errors.append( + issue( + f"claims[{index}].status", + f"必须是 {sorted(ALLOWED['claims.status'])} 之一", + ) + ) + if ( + claim.get("type") in {"fact", "comparison"} + and claim.get("status") == "approved" + and not claim.get("evidence_ids") + ): + errors.append( + issue( + f"claims[{index}].evidence_ids", + "已批准的事实或比较主张必须有证据", + ) + ) + outputs = claim.get("allowed_outputs", []) + if outputs is not None and not isinstance(outputs, list): + errors.append( + issue(f"claims[{index}].allowed_outputs", "必须是数组") + ) + for output in outputs or []: + if output not in OUTPUT_TYPES: + errors.append( + issue( + f"claims[{index}].allowed_outputs", + f"未知产物类型:{output}", + ) + ) + + for index, evidence in enumerate(records["evidence"]): + if not isinstance(evidence, dict): + continue + disclosure = evidence.get("disclosure") + if disclosure not in ALLOWED["disclosure"]: + errors.append( + issue( + f"evidence[{index}].disclosure", + f"必须是 {sorted(ALLOWED['disclosure'])} 之一", + ) + ) + verified_on = evidence.get("verified_on") + if is_nonempty_string(verified_on): + try: + dt.date.fromisoformat(verified_on) + except ValueError: + errors.append( + issue( + f"evidence[{index}].verified_on", + "必须使用 YYYY-MM-DD", + ) + ) + source = evidence.get("source", "") + if is_nonempty_string(source) and SECRET_QUERY.search(source): + errors.append( + issue( + f"evidence[{index}].source", + "来源中疑似包含密钥或令牌参数", + ) + ) + + for index, differentiator in enumerate(records["differentiators"]): + if isinstance(differentiator, dict) and not differentiator.get( + "evidence_ids" + ): + warnings.append( + issue( + f"differentiators[{index}].evidence_ids", + "差异点缺少证据", + ) + ) + + validate_disclosure_chain(records, errors) + if channel in DISCLOSURE_RANK: + validate_channel(records, channel, errors) + + for path, value in walk_strings(data): + if PLACEHOLDERS.search(value): + warnings.append(issue(path, f"包含占位内容:{value[:60]}")) + + if not evidence_ids: + warnings.append(issue("evidence", "没有任何证据记录")) + return errors, warnings + + +def walk_strings(value, path="$"): + if isinstance(value, dict): + for key, item in value.items(): + yield from walk_strings(item, f"{path}.{key}") + elif isinstance(value, list): + for index, item in enumerate(value): + yield from walk_strings(item, f"{path}[{index}]") + elif isinstance(value, str): + yield path, value + + +def parse_args(): + parser = argparse.ArgumentParser(description="验证产品事实与证据清单") + parser.add_argument("manifest", help="product-evidence.json") + parser.add_argument("--json", action="store_true", help="输出 JSON") + parser.add_argument( + "--strict", + action="store_true", + help="存在占位符、缺证据等警告时也失败", + ) + parser.add_argument( + "--channel", + choices=sorted(DISCLOSURE_RANK), + help="目标发布渠道,校验主张引用证据的公开级别是否足够", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + path = Path(args.manifest).expanduser().resolve() + with path.open(encoding="utf-8") as handle: + data = json.load(handle) + errors, warnings = validate_manifest(data, channel=args.channel) + report = { + "file": str(path), + "channel": args.channel, + "valid": not errors and (not args.strict or not warnings), + "errors": errors, + "warnings": warnings, + "stats": { + collection: len(data.get(collection, [])) + if isinstance(data, dict) and isinstance(data.get(collection, []), list) + else 0 + for collection in COLLECTIONS + }, + } + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + for level, items in (("错误", errors), ("警告", warnings)): + for item in items: + print(f"[{level}] {item['path']}:{item['message']}") + print( + f"核验完成:错误 {len(errors)},警告 {len(warnings)}," + f"状态 {'通过' if report['valid'] else '失败'}" + ) + return 0 if report["valid"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/skills/product-evidence/templates/product-evidence.example.json b/resources/skills/product-evidence/templates/product-evidence.example.json new file mode 100644 index 0000000..0ea9a72 --- /dev/null +++ b/resources/skills/product-evidence/templates/product-evidence.example.json @@ -0,0 +1,120 @@ +{ + "schema_version": 1, + "product": { + "name": "示例产品", + "version": "1.0", + "category": "产品类别", + "summary": "用一句可验证的话说明产品面向谁、解决什么问题。", + "maturity": "released", + "audiences": [ + "技术负责人", + "业务负责人" + ], + "deployment_modes": [ + "私有化部署" + ] + }, + "terminology": [ + { + "term": "标准术语", + "definition": "术语在所有材料中的统一定义。", + "avoid": [ + "不再使用的旧称谓" + ] + } + ], + "features": [ + { + "id": "FEAT-001", + "name": "示例功能", + "summary": "说明用户可执行的动作和可观察结果。", + "status": "released", + "availability": "标准版本", + "evidence_ids": [ + "EVD-001" + ], + "use_case_ids": [ + "CASE-001" + ] + } + ], + "parameters": [ + { + "id": "PAR-001", + "name": "示例技术参数", + "value": "已核验值", + "unit": "", + "conditions": "说明测试条件、版本和统计口径。", + "scope": "标准版本", + "disclosure": "public", + "evidence_ids": [ + "EVD-001" + ] + } + ], + "claims": [ + { + "id": "CLM-001", + "text": "产品在指定版本和条件下提供示例功能。", + "type": "fact", + "status": "approved", + "allowed_outputs": [ + "feature-catalog", + "technical-proposal", + "presentation" + ], + "evidence_ids": [ + "EVD-001" + ] + } + ], + "use_cases": [ + { + "id": "CASE-001", + "name": "示例场景", + "audience": "技术负责人", + "problem": "说明现有工作中的具体问题。", + "workflow": "说明产品参与的步骤和人工复核点。", + "outcome": "只写已有证据支持的结果。", + "evidence_ids": [ + "EVD-001" + ] + } + ], + "differentiators": [ + { + "id": "DIF-001", + "statement": "说明可验证的产品差异。", + "comparison_scope": "明确比较对象和版本范围。", + "evidence_ids": [ + "EVD-001" + ] + } + ], + "limitations": [ + { + "id": "LIM-001", + "text": "说明适用边界、依赖条件或人工复核要求。", + "applies_to": [ + "FEAT-001" + ] + } + ], + "evidence": [ + { + "id": "EVD-001", + "type": "product-documentation", + "title": "产品说明书", + "source": "docs/product.md", + "locator": "功能章节", + "verified_on": "2026-01-01", + "owner": "产品负责人", + "disclosure": "public", + "notes": "发布前重新核验版本。" + } + ], + "prohibited_claims": [ + "绝对领先", + "百分之百准确" + ] +} diff --git a/resources/skills/product-evidence/tests/test_validate_evidence.py b/resources/skills/product-evidence/tests/test_validate_evidence.py new file mode 100644 index 0000000..78a99ea --- /dev/null +++ b/resources/skills/product-evidence/tests/test_validate_evidence.py @@ -0,0 +1,113 @@ +import copy +import importlib.util +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SKILL_DIR = Path(__file__).resolve().parents[1] +SCRIPT = SKILL_DIR / "scripts" / "validate_evidence.py" +SPEC = importlib.util.spec_from_file_location("validate_evidence", SCRIPT) +validate_evidence = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(validate_evidence) + + +class ProductEvidenceTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + template = SKILL_DIR / "templates" / "product-evidence.example.json" + cls.valid_data = json.loads(template.read_text(encoding="utf-8")) + + def test_example_manifest_is_valid(self): + errors, warnings = validate_evidence.validate_manifest(self.valid_data) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_rejects_duplicate_ids_and_unknown_references(self): + data = copy.deepcopy(self.valid_data) + data["parameters"][0]["id"] = "FEAT-001" + data["claims"][0]["evidence_ids"] = ["EVD-MISSING"] + errors, _ = validate_evidence.validate_manifest(data) + messages = "\n".join(item["message"] for item in errors) + self.assertIn("重复", messages) + self.assertIn("引用不存在", messages) + + def test_rejects_secret_in_evidence_source(self): + data = copy.deepcopy(self.valid_data) + data["evidence"][0]["source"] = "https://example.test/doc?token=secret" + errors, _ = validate_evidence.validate_manifest(data) + self.assertTrue( + any("密钥" in item["message"] for item in errors) + ) + + def test_placeholder_is_warning(self): + data = copy.deepcopy(self.valid_data) + data["product"]["summary"] = "TODO" + errors, warnings = validate_evidence.validate_manifest(data) + self.assertEqual(errors, []) + self.assertTrue(warnings) + + def test_rejects_record_more_public_than_evidence(self): + data = copy.deepcopy(self.valid_data) + for record in data["evidence"]: + if record["id"] == "EVD-001": + record["disclosure"] = "restricted" + errors, _ = validate_evidence.validate_manifest(data) + self.assertTrue( + any("仅为 restricted" in item["message"] for item in errors) + ) + + def test_restricted_parameter_is_not_a_public_channel_warning(self): + data = copy.deepcopy(self.valid_data) + data["parameters"][0]["disclosure"] = "restricted" + for record in data["evidence"]: + if record["id"] in data["parameters"][0]["evidence_ids"]: + record["disclosure"] = "restricted" + data["claims"][0]["evidence_ids"] = [] + data["claims"][0]["type"] = "goal" + errors, warnings = validate_evidence.validate_manifest( + data, channel="public" + ) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_public_channel_rejects_internal_evidence(self): + data = copy.deepcopy(self.valid_data) + claim = data["claims"][0] + claim["allowed_outputs"] = ["one-pager"] + evidence_id = claim["evidence_ids"][0] + for record in data["evidence"]: + if record["id"] == evidence_id: + record["disclosure"] = "internal" + for parameter in data["parameters"]: + if evidence_id in parameter.get("evidence_ids", []): + parameter["disclosure"] = "internal" + errors, _ = validate_evidence.validate_manifest(data, channel="public") + self.assertTrue( + any("不足以支撑 public" in item["message"] for item in errors) + ) + errors, _ = validate_evidence.validate_manifest(data, channel="internal") + self.assertEqual(errors, []) + + def test_cli_json_report(self): + with tempfile.TemporaryDirectory() as tmp: + manifest = Path(tmp) / "product-evidence.json" + manifest.write_text( + json.dumps(self.valid_data, ensure_ascii=False), + encoding="utf-8", + ) + result = subprocess.run( + [sys.executable, str(SCRIPT), str(manifest), "--json"], + check=True, + capture_output=True, + text=True, + ) + report = json.loads(result.stdout) + self.assertTrue(report["valid"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/resources/skills/product-feature-catalog/SKILL.md b/resources/skills/product-feature-catalog/SKILL.md new file mode 100644 index 0000000..b91cd57 --- /dev/null +++ b/resources/skills/product-feature-catalog/SKILL.md @@ -0,0 +1,56 @@ +--- +name: product-feature-catalog +version: 1.0.0 +description: | + 基于产品事实与证据生成结构化功能列表、模块树和版本能力矩阵。用于产品规划、 + 售前交流、招标附件或交付范围梳理;重点回答产品有什么功能、谁使用、产生什么 + 结果,不负责撰写技术参数或长篇方案。 +allowed-tools: + - Read + - Grep + - Glob +compatibility: Markdown;建议配合 product-evidence +--- + +# 产品功能列表 + +## 必要输入 + +- `product-evidence.json`,至少包含 `product`、`features`、`limitations`。 +- 输出用途:内部全量、公开产品、指定版本或投标范围。 +- 目标受众和所需粒度:模块、功能或子功能。 + +没有事实清单时不要自行拼凑:先用 `product-evidence` 建立并校验清单,或输出输入 +缺口清单等待补齐;任何情况下都不得根据产品名称猜测功能。 + +## 生成流程 + +1. 按版本、公开级别和用途筛选功能。 +2. 建立“产品域 → 一级模块 → 功能 → 子功能”层级,层级只服务导航,不凑数量。 +3. 每条功能写清用户角色、触发动作、系统行为和可观察结果。 +4. 标注 `released`、`beta`、`planned`、`deprecated`,规划能力不能混入现有功能。 +5. 补充版本范围、依赖、限制和证据 ID。 +6. 合并同义功能,拆开一行中包含多个独立用户动作的复合功能。 + +## 输出 + +复制并填写 `templates/feature-catalog.md`。默认输出两部分: + +1. 面向读者的功能目录,只保留必要列。 +2. 追溯附表,保留功能 ID、状态、证据和限制,供内部审核。 + +## 写作规则 + +- 功能名使用“对象 + 动作”或稳定名词,不用“强大、智能、高效、全方位”。 +- 功能说明写能力边界,不写架构实现、性能参数和竞争结论。 +- “支持”后必须接具体对象或动作,避免“全面支持”“灵活支持”。 +- 同一功能在不同版本有差异时拆行或使用版本矩阵,不能用模糊脚注掩盖。 +- 公开清单不得带出 `restricted`、`internal` 证据内容。 + +## 完成标准 + +- 每条功能可追溯到事实清单 ID。 +- 当前能力、试用能力和规划能力明确分开。 +- 模块树无重复、孤立节点和伪造层级。 +- 功能粒度基本一致,名称、术语和版本范围统一。 +- 限制条件没有因表格精简而丢失。 diff --git a/resources/skills/product-feature-catalog/templates/feature-catalog.md b/resources/skills/product-feature-catalog/templates/feature-catalog.md new file mode 100644 index 0000000..8301af7 --- /dev/null +++ b/resources/skills/product-feature-catalog/templates/feature-catalog.md @@ -0,0 +1,27 @@ +# {{产品名称}}功能列表 + +**产品版本**:{{版本}} +**适用范围**:{{版本/部署方式/授权范围}} +**清单日期**:{{YYYY-MM-DD}} + +## 功能目录 + +| 一级模块 | 二级模块 | 功能名称 | 使用角色 | 用户动作与结果 | 版本范围 | 状态 | +|---|---|---|---|---|---|---| +| {{模块}} | {{子模块}} | {{对象+动作}} | {{角色}} | {{触发动作;系统行为;可观察结果}} | {{版本}} | released | + +## 版本能力矩阵 + +| 功能 ID | 标准版 | 专业版 | 企业版 | 依赖条件 | +|---|---:|---:|---:|---| +| {{FEAT-001}} | ✓ | ✓ | ✓ | {{依赖}} | + +## 限制与说明 + +- {{功能 ID}}:{{适用边界、依赖或人工复核要求}}。 + +## 内部追溯表 + +| 功能 ID | 功能名称 | 状态 | 证据 ID | 复核结果 | +|---|---|---|---|---| +| {{FEAT-001}} | {{功能}} | released | {{EVD-001}} | {{通过/待核验}} | diff --git a/resources/skills/product-marketing/SKILL.md b/resources/skills/product-marketing/SKILL.md new file mode 100644 index 0000000..1a19b91 --- /dev/null +++ b/resources/skills/product-marketing/SKILL.md @@ -0,0 +1,200 @@ +--- +name: product-marketing +version: 1.0.0 +description: | + 编排产品事实、功能列表、招标参数、产品 PPT、技术方案、一页纸、白皮书、招标响应、 + 演示套件、客户案例和竞品定位等多个独立技能。用于一次请求需要选择或组合多种 + 产品市场产物,并确保它们共享同一事实版本、术语和承诺边界。 +allowed-tools: + - Read + - Grep + - Glob + - Execute +compatibility: 可独立规划;执行节点需要对应子技能可用 +--- + +# 产品市场总编排 + +本技能只负责需求澄清、路由、依赖、门禁和跨产物一致性。各产物的方法和模板由 +对应独立技能负责,不能在总技能中重新实现一份简化版本。 + +## 子技能 + +| 技能 | 唯一职责 | +|---|---| +| `product-evidence` | 冻结产品事实、状态、证据和限制 | +| `product-feature-catalog` | 功能目录和版本矩阵 | +| `tender-technical-spec` | 可采购、可验收的招标技术规格 | +| `product-presentation` | 产品介绍 PPT 与讲稿 | +| `technical-proposal` | 客户或投标技术方案 | +| `product-one-pager` | 一页纸产品概览和彩页文案 | +| `solution-whitepaper` | 原理、架构、证据和边界白皮书 | +| `tender-response-matrix` | 招标要求逐条响应、缺口和偏离 | +| `sales-demo-kit` | 可执行演示套件、回退和演练材料 | +| `customer-case-study` | 经授权且可复核的客户案例 | +| `competitive-positioning` | 有来源的竞品矩阵和定位 | + +`deai-writing` 和 `longdoc-docx` 是可选质量与导出技能,不承担产品事实判断。 + +## 第一步:形成任务简报 + +必须确认: + +- 产品和版本。 +- 目标受众、决策目标和发布渠道。 +- 需要的产物、格式、篇幅、语言和截止时间。 +- 公开级别、客户信息和竞品信息的使用权限。 +- 原始事实材料、招标文件、客户需求和品牌资产。 +- 最终审批人。 + +信息不足时把缺口写入计划,不默认补齐。 + +## 第二步:生成路由计划 + +复制 `templates/route-plan.example.json`,只选择完成请求所需节点: + +先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux +优先使用 `python3`。下文 `` 表示探测成功的解释器命令。 + +```bash + "/scripts/validate_route_plan.py" ./route-plan.json +``` + +所有产物必须直接或间接依赖唯一的 `product-evidence` 节点。节点缺少对应技能时 +明确报告缺失,不允许由总技能静默生成低质量替代物。 + +`depends_on` 表示硬依赖:被依赖节点未 `completed` 时,本节点不能进入 `running` +或 `completed`。可选输入(例如尚无授权的客户案例)不要写进 `depends_on`,而是 +在 `reason` 中说明“可用则引用,不可用则不提及”。 + +## 推荐路由 + +### 投标响应(我方应标) + +```text +product-evidence + → tender-response-matrix(提取要求与缺口) + → technical-proposal + → tender-response-matrix(回填方案章节和证明材料) +``` + +### 招标文件编制(采购方) + +```text +product-evidence → product-feature-catalog → tender-technical-spec +``` + +`tender-technical-spec` 面向采购参数编写,不参与我方符合性判定,两条路由不要 +混用。 + +### 客户方案 + +```text +product-evidence + → product-feature-catalog + → technical-proposal + → [product-presentation, product-one-pager] +``` + +PPT 和一页纸从已批准方案摘要派生,避免重新解释范围。 + +### 产品发布与销售 + +```text +product-evidence + → product-feature-catalog + → [competitive-positioning, customer-case-study] + → product-one-pager + → product-presentation + → sales-demo-kit +``` + +客户案例没有授权时把该节点标记 `skipped` 且 `required` 为 false,其他节点可 +继续,但不得引用该案例。 + +### 白皮书 + +```text +product-evidence → product-feature-catalog → solution-whitepaper +``` + +## 第三步:执行门禁 + +每个节点开始前检查依赖是否通过;失败节点的下游必须阻断,不能标记成功。执行 +过程中更新 `status`,交付前用 final 阶段复核: + +```bash + "/scripts/validate_route_plan.py" ./route-plan.json \ + --phase final --output-root . +``` + +final 阶段要求必需节点全部 `completed`、依赖链无未完成节点,并核验每个声明产物 +文件存在且非空。 + +门禁分两类: + +- `evidence-validation`、`cross-artifact-consistency` 由本套件自行判定,必须 + `passed`,不能豁免。 +- `confidentiality-review`、`human-approval` 取决于组织流程。需要评审时记为 + `passed`;组织不要求时记为 `waived` 并在 `waiver` 写明责任人与理由,校验通过 + 但会输出警告,保留豁免记录。 + +最终统一核对: + +- 产品名称、版本、功能状态和术语一致。 +- 相同参数的数值、单位、条件和统计口径一致。 +- `planned`、`beta`、`released` 没有跨产物变形。 +- 客户案例、Logo、引语和竞品结论权限一致。 +- 技术方案、PPT、一页纸和演示套件使用相同架构与工作流。 +- 所有对外主张可追溯到同一版 `product-evidence.json`。 +- 不含密钥、私有地址、内部证据路径和未批准承诺。 + +## 第四步:质量与导出 + +中文叙事产物可调用 `deai-writing`;长文可调用 `longdoc-docx`;PPT 使用 +`product-presentation` 自带构建器。质量工具只能修正表达和排版,不能更改事实、 +成熟度、参数或授权边界。 + +### 交付物形态 + +Markdown 与 JSON 是中间产物,不是交付物。除非用户另有指定,交付物为: + +| 产物 | 交付格式 | +| --- | --- | +| 文档类(技术方案、白皮书、功能列表、响应矩阵、招标规格、一页纸、演示套件、竞争定位) | DOCX | +| 产品 PPT | PPTX | + +PDF 只作为排版核验中间件,核验后删除,不放入交付目录;用户明确要求时才交付。 + +### 目录与密级 + +中间产物与交付物必须分离,交付目录按各产物自身声明的密级分区: + +```text +build/ # 事实清单、路由计划、Markdown、构建配置 + check/ # 核验用 PDF、核验报告、页面 PNG +deliverables/ + public/ # 可公开 + restricted/ # 受控客户交流与投标 + internal/ # 仅内部 +``` + +各子技能的 `output` 一律指向 `deliverables/<密级>/`,核验产物一律写入 +`build/check/`。交付目录内只允许出现 DOCX 与 PPTX。 + +单文件产物直接放 `build/<产物名>.md`。技术方案、白皮书等需要分章节的长文,改用 +`build/<产物名>/` 子目录,内部按 `longdoc-docx` 的 `chapters/`、`assets/`、 +`drafts/` 分层,避免多个长文的章节文件在 `build/` 根目录互相混淆。 + +密级以产物自身标注为准,不得由编排者主观下调。分区后须扫描公开级产物,确认 +未夹带受控结论、内部路径与凭据。 + +路由计划节点的 `outputs` 必须声明真实交付物路径,Markdown 与 JSON 记入 +`intermediates`。若 `outputs` 指向中间产物,交付门禁将只校验中间产物而放行缺失 +的真实交付物。 + +## 完成标准 + +路由计划通过校验;所有必需节点完成;无失败依赖被忽略;各产物共享同一事实版本; +跨产物一致性、保密审查和人工审批全部通过,并保留选择、跳过和阻断理由;交付目录 +中每个产物都以约定格式实际存在,且已按密级分区。 diff --git a/resources/skills/product-marketing/scripts/validate_route_plan.py b/resources/skills/product-marketing/scripts/validate_route_plan.py new file mode 100644 index 0000000..3e418b4 --- /dev/null +++ b/resources/skills/product-marketing/scripts/validate_route_plan.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Validate product-marketing orchestration route plans.""" + +import argparse +import json +import re +import sys +from pathlib import Path, PurePosixPath + + +SKILL_ARTIFACT = { + "product-evidence": "evidence", + "product-feature-catalog": "feature-catalog", + "tender-technical-spec": "technical-spec", + "product-presentation": "presentation", + "technical-proposal": "technical-proposal", + "product-one-pager": "one-pager", + "solution-whitepaper": "whitepaper", + "tender-response-matrix": "tender-response", + "sales-demo-kit": "sales-demo", + "customer-case-study": "case-study", + "competitive-positioning": "competitive-positioning", +} +STATUSES = {"pending", "running", "completed", "failed", "skipped"} +GATE_STATUSES = {"pending", "passed", "failed", "waived"} +CONFIDENTIALITY = {"public", "restricted", "internal"} +# Gates this suite can evaluate itself; they must actually pass. +CORE_GATES = {"evidence-validation", "cross-artifact-consistency"} +# Gates that depend on organizational policy; they may be waived with a reason. +POLICY_GATES = {"confidentiality-review", "human-approval"} +REQUIRED_GATES = CORE_GATES | POLICY_GATES +SECRET_PATTERN = re.compile(r"(?i)(?:token|api[_-]?key|secret|password)=") + + +def issue(path, message): + return {"path": path, "message": message} + + +def nonempty(value): + return isinstance(value, str) and bool(value.strip()) + + +def validate_output_path(value, path, errors): + if not nonempty(value): + errors.append(issue(path, "必须是非空字符串")) + return + normalized = value.replace("\\", "/") + pure = PurePosixPath(normalized) + if pure.is_absolute() or ".." in pure.parts: + errors.append(issue(path, "必须是工作目录内的相对路径")) + if SECRET_PATTERN.search(value): + errors.append(issue(path, "路径疑似包含密钥或令牌")) + + +def known_dependencies(node, nodes_by_id): + return [ + dependency + for dependency in node.get("depends_on", []) + if isinstance(dependency, str) and dependency in nodes_by_id + ] + + +def topological_order(nodes_by_id): + """Return (order, cycle). Kahn's algorithm keeps deep graphs iterative.""" + dependents = {node_id: [] for node_id in nodes_by_id} + remaining = {} + for node_id, node in nodes_by_id.items(): + dependencies = set(known_dependencies(node, nodes_by_id)) - {node_id} + remaining[node_id] = len(dependencies) + for dependency in dependencies: + dependents[dependency].append(node_id) + + queue = [node_id for node_id, count in remaining.items() if count == 0] + order = [] + while queue: + node_id = queue.pop() + order.append(node_id) + for dependent in dependents[node_id]: + remaining[dependent] -= 1 + if remaining[dependent] == 0: + queue.append(dependent) + + if len(order) == len(nodes_by_id): + return order, None + return order, sorted(node_id for node_id in nodes_by_id if remaining[node_id] > 0) + + +def evidence_reachability(order, nodes_by_id, evidence_id): + reaches = {} + for node_id in order: + dependencies = known_dependencies(nodes_by_id[node_id], nodes_by_id) + reaches[node_id] = any( + dependency == evidence_id or reaches.get(dependency, False) + for dependency in dependencies + ) + return reaches + + +def validate_plan(data, phase="plan", output_root=None): + errors = [] + warnings = [] + if not isinstance(data, dict): + return [issue("$", "根节点必须是对象")], warnings + if data.get("schema_version") != 1: + errors.append(issue("schema_version", "当前仅支持整数 1")) + + request = data.get("request") + if not isinstance(request, dict): + errors.append(issue("request", "必须是对象")) + request = {} + for field in ("id", "objective", "channel", "evidence_ref", "product_version"): + if not nonempty(request.get(field)): + errors.append(issue(f"request.{field}", "必须是非空字符串")) + if request.get("confidentiality") not in CONFIDENTIALITY: + errors.append( + issue( + "request.confidentiality", + f"必须是 {sorted(CONFIDENTIALITY)} 之一", + ) + ) + if nonempty(request.get("evidence_ref")): + validate_output_path( + request["evidence_ref"], + "request.evidence_ref", + errors, + ) + + nodes = data.get("nodes") + if not isinstance(nodes, list) or not nodes: + errors.append(issue("nodes", "必须是非空数组")) + return errors, warnings + + nodes_by_id = {} + all_outputs = {} + evidence_nodes = [] + for index, node in enumerate(nodes): + path = f"nodes[{index}]" + if not isinstance(node, dict): + errors.append(issue(path, "必须是对象")) + continue + node_id = node.get("id") + if not nonempty(node_id): + errors.append(issue(f"{path}.id", "必须是非空字符串")) + continue + if node_id in nodes_by_id: + errors.append(issue(f"{path}.id", f"节点 ID 重复:{node_id}")) + continue + nodes_by_id[node_id] = node + skill = node.get("skill") + if skill not in SKILL_ARTIFACT: + errors.append(issue(f"{path}.skill", f"未知技能:{skill!r}")) + elif node.get("artifact_type") != SKILL_ARTIFACT[skill]: + errors.append( + issue( + f"{path}.artifact_type", + f"{skill} 应生成 {SKILL_ARTIFACT[skill]}", + ) + ) + if skill == "product-evidence": + evidence_nodes.append(node_id) + dependencies = node.get("depends_on") + if not isinstance(dependencies, list): + errors.append(issue(f"{path}.depends_on", "必须是数组")) + elif len(dependencies) != len(set(dependencies)): + errors.append(issue(f"{path}.depends_on", "存在重复依赖")) + if not isinstance(node.get("required"), bool): + errors.append(issue(f"{path}.required", "必须是布尔值")) + if node.get("status") not in STATUSES: + errors.append( + issue( + f"{path}.status", + f"必须是 {sorted(STATUSES)} 之一", + ) + ) + if node.get("required") is True and node.get("status") == "skipped": + errors.append(issue(f"{path}.status", "必需节点不能标记 skipped")) + if not nonempty(node.get("reason")): + errors.append(issue(f"{path}.reason", "必须说明选择理由")) + outputs = node.get("outputs") + if not isinstance(outputs, list) or not outputs: + errors.append(issue(f"{path}.outputs", "必须是非空数组")) + else: + for output_index, output in enumerate(outputs): + output_path = f"{path}.outputs[{output_index}]" + validate_output_path(output, output_path, errors) + if output in all_outputs: + errors.append( + issue( + output_path, + f"输出与 {all_outputs[output]} 重复:{output}", + ) + ) + else: + all_outputs[output] = node_id + + for node_id, node in nodes_by_id.items(): + for dependency in node.get("depends_on", []): + if dependency == node_id: + errors.append( + issue(f"nodes.{node_id}.depends_on", "不能依赖自身") + ) + elif dependency not in nodes_by_id: + errors.append( + issue( + f"nodes.{node_id}.depends_on", + f"依赖节点不存在:{dependency}", + ) + ) + + order, cycle = topological_order(nodes_by_id) + if cycle: + errors.append(issue("nodes", f"依赖存在环:{'、'.join(cycle)}")) + + if len(evidence_nodes) != 1: + errors.append(issue("nodes", "必须且只能有一个 product-evidence 节点")) + elif not cycle: + evidence_id = evidence_nodes[0] + reaches = evidence_reachability(order, nodes_by_id, evidence_id) + for node_id in nodes_by_id: + if node_id != evidence_id and not reaches.get(node_id, False): + errors.append( + issue( + f"nodes.{node_id}.depends_on", + "所有产物必须直接或间接依赖 product-evidence", + ) + ) + + validate_execution(nodes_by_id, errors) + validate_gates(data.get("final_gates"), errors, warnings, phase) + if phase == "final": + validate_final_phase(nodes_by_id, output_root, errors) + + if len(nodes_by_id) == 1: + warnings.append(issue("nodes", "路由计划没有任何最终产物节点")) + return errors, warnings + + +def validate_execution(nodes_by_id, errors): + for node_id, node in nodes_by_id.items(): + status = node.get("status") + if status not in ("running", "completed"): + continue + for dependency in known_dependencies(node, nodes_by_id): + dependency_node = nodes_by_id[dependency] + dependency_status = dependency_node.get("status") + if dependency_status == "completed": + continue + if ( + dependency_status == "skipped" + and dependency_node.get("required") is False + ): + continue + errors.append( + issue( + f"nodes.{node_id}.status", + f"依赖 {dependency} 状态为 {dependency_status}," + f"不能标记 {status}", + ) + ) + + +def validate_gates(gates, errors, warnings, phase): + if not isinstance(gates, list): + errors.append(issue("final_gates", "必须是数组")) + return {} + resolved = {} + for index, gate in enumerate(gates): + path = f"final_gates[{index}]" + if isinstance(gate, str): + if not nonempty(gate): + errors.append(issue(path, "门禁名称不能为空")) + continue + resolved[gate] = {"status": None, "waiver": None} + elif isinstance(gate, dict): + name = gate.get("name") + if not nonempty(name): + errors.append(issue(f"{path}.name", "门禁名称不能为空")) + continue + status = gate.get("status") + if status not in GATE_STATUSES: + errors.append( + issue( + f"{path}.status", + f"必须是 {sorted(GATE_STATUSES)} 之一", + ) + ) + continue + waiver = gate.get("waiver") + if status == "waived": + if name not in POLICY_GATES: + errors.append( + issue( + f"{path}.status", + f"{name} 由本套件自行判定,不能豁免", + ) + ) + continue + if not nonempty(waiver): + errors.append( + issue(f"{path}.waiver", "豁免必须写明责任人与理由") + ) + continue + resolved[name] = {"status": status, "waiver": waiver} + else: + errors.append(issue(path, "必须是字符串或对象")) + missing = REQUIRED_GATES - set(resolved) + if missing: + errors.append(issue("final_gates", f"缺少门禁:{sorted(missing)}")) + if phase == "final": + for name in sorted(REQUIRED_GATES & set(resolved)): + status = resolved[name]["status"] + if status == "passed": + continue + if status == "waived": + warnings.append( + issue( + "final_gates", + f"{name} 已豁免:{resolved[name]['waiver']}", + ) + ) + continue + errors.append( + issue( + "final_gates", + f"交付前门禁 {name} 必须记录 passed,当前为 " + f"{status or '未记录'}", + ) + ) + return resolved + + +def validate_final_phase(nodes_by_id, output_root, errors): + for node_id, node in nodes_by_id.items(): + status = node.get("status") + if node.get("required") is True and status != "completed": + errors.append( + issue( + f"nodes.{node_id}.status", + f"交付前必需节点必须 completed,当前为 {status}", + ) + ) + elif status not in ("completed", "skipped"): + errors.append( + issue( + f"nodes.{node_id}.status", + f"交付前可选节点必须 completed 或 skipped,当前为 {status}", + ) + ) + if status != "completed" or output_root is None: + continue + outputs = node.get("outputs") + if not isinstance(outputs, list): + continue + for output in outputs: + if not nonempty(output): + continue + artifact = output_root / output + if not artifact.is_file(): + errors.append( + issue( + f"nodes.{node_id}.outputs", + f"声明的产物不存在:{output}", + ) + ) + elif artifact.stat().st_size == 0: + errors.append( + issue( + f"nodes.{node_id}.outputs", + f"产物为空文件:{output}", + ) + ) + + +def parse_args(): + parser = argparse.ArgumentParser(description="验证产品市场技能路由计划") + parser.add_argument("plan", help="route-plan.json") + parser.add_argument( + "--phase", + choices=("plan", "final"), + default="plan", + help="plan 校验结构,final 追加交付前状态与产物核验", + ) + parser.add_argument( + "--output-root", + help="final 阶段解析 outputs 相对路径的根目录", + ) + parser.add_argument("--json", action="store_true") + return parser.parse_args() + + +def main(): + args = parse_args() + path = Path(args.plan).expanduser().resolve() + output_root = ( + Path(args.output_root).expanduser().resolve() if args.output_root else None + ) + with path.open(encoding="utf-8") as handle: + data = json.load(handle) + errors, warnings = validate_plan(data, phase=args.phase, output_root=output_root) + report = { + "file": str(path), + "phase": args.phase, + "valid": not errors, + "errors": errors, + "warnings": warnings, + "nodes": len(data.get("nodes", [])) if isinstance(data, dict) else 0, + } + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + for level, items in (("错误", errors), ("警告", warnings)): + for item in items: + print(f"[{level}] {item['path']}:{item['message']}") + print( + f"路由核验({args.phase}):错误 {len(errors)},警告 {len(warnings)}," + f"状态 {'通过' if report['valid'] else '失败'}" + ) + return 0 if report["valid"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/skills/product-marketing/templates/route-plan.example.json b/resources/skills/product-marketing/templates/route-plan.example.json new file mode 100644 index 0000000..9626042 --- /dev/null +++ b/resources/skills/product-marketing/templates/route-plan.example.json @@ -0,0 +1,77 @@ +{ + "schema_version": 1, + "request": { + "id": "PM-001", + "objective": "为目标客户准备产品介绍与技术方案", + "audiences": [ + "技术负责人", + "业务负责人" + ], + "channel": "受控客户交流", + "confidentiality": "restricted", + "evidence_ref": "product-evidence.json", + "product_version": "1.0" + }, + "nodes": [ + { + "id": "evidence", + "skill": "product-evidence", + "artifact_type": "evidence", + "depends_on": [], + "required": true, + "status": "pending", + "outputs": [ + "product-evidence.json" + ], + "reason": "所有产物共享同一事实版本" + }, + { + "id": "catalog", + "skill": "product-feature-catalog", + "artifact_type": "feature-catalog", + "depends_on": [ + "evidence" + ], + "required": true, + "status": "pending", + "outputs": [ + "feature-catalog.md" + ], + "reason": "先固定产品能力范围" + }, + { + "id": "proposal", + "skill": "technical-proposal", + "artifact_type": "technical-proposal", + "depends_on": [ + "catalog" + ], + "required": true, + "status": "pending", + "outputs": [ + "technical-proposal.md" + ], + "reason": "客户方案依赖已核验能力目录" + }, + { + "id": "presentation", + "skill": "product-presentation", + "artifact_type": "presentation", + "depends_on": [ + "proposal" + ], + "required": true, + "status": "pending", + "outputs": [ + "product-presentation.pptx" + ], + "reason": "PPT 从已批准方案摘要派生" + } + ], + "final_gates": [ + { "name": "evidence-validation", "status": "pending" }, + { "name": "cross-artifact-consistency", "status": "pending" }, + { "name": "confidentiality-review", "status": "pending" }, + { "name": "human-approval", "status": "pending" } + ] +} diff --git a/resources/skills/product-marketing/tests/test_validate_route_plan.py b/resources/skills/product-marketing/tests/test_validate_route_plan.py new file mode 100644 index 0000000..0569e2d --- /dev/null +++ b/resources/skills/product-marketing/tests/test_validate_route_plan.py @@ -0,0 +1,157 @@ +import copy +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +SKILL_DIR = Path(__file__).resolve().parents[1] +SCRIPT = SKILL_DIR / "scripts" / "validate_route_plan.py" +SPEC = importlib.util.spec_from_file_location("validate_route_plan", SCRIPT) +validate_route_plan = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(validate_route_plan) + + +class RoutePlanTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + template = SKILL_DIR / "templates" / "route-plan.example.json" + cls.plan = json.loads(template.read_text(encoding="utf-8")) + + def test_example_plan_is_valid(self): + errors, warnings = validate_route_plan.validate_plan(self.plan) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_rejects_cycle(self): + plan = copy.deepcopy(self.plan) + plan["nodes"][0]["depends_on"] = ["presentation"] + errors, _ = validate_route_plan.validate_plan(plan) + self.assertTrue(any("依赖存在环" in item["message"] for item in errors)) + + def test_rejects_artifact_without_evidence_dependency(self): + plan = copy.deepcopy(self.plan) + plan["nodes"][1]["depends_on"] = [] + errors, _ = validate_route_plan.validate_plan(plan) + self.assertTrue( + any("product-evidence" in item["message"] for item in errors) + ) + + def test_rejects_duplicate_output(self): + plan = copy.deepcopy(self.plan) + plan["nodes"][2]["outputs"] = ["feature-catalog.md"] + errors, _ = validate_route_plan.validate_plan(plan) + self.assertTrue(any("输出与" in item["message"] for item in errors)) + + def test_rejects_completed_node_with_unfinished_dependency(self): + plan = copy.deepcopy(self.plan) + plan["nodes"][1]["status"] = "completed" + errors, _ = validate_route_plan.validate_plan(plan) + self.assertTrue( + any("不能标记 completed" in item["message"] for item in errors) + ) + + def test_final_phase_requires_completion_and_artifacts(self): + plan = copy.deepcopy(self.plan) + errors, _ = validate_route_plan.validate_plan(plan, phase="final") + self.assertTrue( + any("必须 completed" in item["message"] for item in errors) + ) + self.assertTrue( + any("必须记录 passed" in item["message"] for item in errors) + ) + + def test_policy_gate_can_be_waived_with_reason(self): + plan = copy.deepcopy(self.plan) + for node in plan["nodes"]: + node["status"] = "completed" + for gate in plan["final_gates"]: + gate["status"] = "passed" + approval = next( + g for g in plan["final_gates"] if g["name"] == "human-approval" + ) + approval["status"] = "waived" + errors, _ = validate_route_plan.validate_plan(plan, phase="final") + self.assertTrue(any("豁免必须写明" in i["message"] for i in errors)) + + approval["waiver"] = "内部草稿,责任人张三,2026-08-01" + errors, warnings = validate_route_plan.validate_plan(plan, phase="final") + self.assertEqual(errors, []) + self.assertTrue(any("已豁免" in i["message"] for i in warnings)) + + def test_core_gate_cannot_be_waived(self): + plan = copy.deepcopy(self.plan) + for node in plan["nodes"]: + node["status"] = "completed" + for gate in plan["final_gates"]: + gate["status"] = "passed" + core = next( + g for g in plan["final_gates"] if g["name"] == "evidence-validation" + ) + core["status"] = "waived" + core["waiver"] = "跳过" + errors, _ = validate_route_plan.validate_plan(plan, phase="final") + self.assertTrue(any("不能豁免" in i["message"] for i in errors)) + + def test_final_phase_accepts_completed_plan_with_outputs(self): + plan = copy.deepcopy(self.plan) + for node in plan["nodes"]: + node["status"] = "completed" + for gate in plan["final_gates"]: + gate["status"] = "passed" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for node in plan["nodes"]: + for output in node["outputs"]: + (root / output).write_text("content", encoding="utf-8") + errors, _ = validate_route_plan.validate_plan( + plan, phase="final", output_root=root + ) + self.assertEqual(errors, []) + missing = copy.deepcopy(plan) + (root / missing["nodes"][-1]["outputs"][0]).unlink() + errors, _ = validate_route_plan.validate_plan( + missing, phase="final", output_root=root + ) + self.assertTrue( + any("产物不存在" in item["message"] for item in errors) + ) + + def test_deep_chain_does_not_recurse(self): + nodes = [ + { + "id": "evidence", + "skill": "product-evidence", + "artifact_type": "evidence", + "depends_on": [], + "required": True, + "status": "pending", + "outputs": ["product-evidence.json"], + "reason": "基线", + } + ] + previous = "evidence" + for index in range(1500): + node_id = f"catalog-{index}" + nodes.append( + { + "id": node_id, + "skill": "product-feature-catalog", + "artifact_type": "feature-catalog", + "depends_on": [previous], + "required": True, + "status": "pending", + "outputs": [f"feature-catalog-{index}.md"], + "reason": "链式依赖", + } + ) + previous = node_id + plan = copy.deepcopy(self.plan) + plan["nodes"] = list(reversed(nodes)) + errors, _ = validate_route_plan.validate_plan(plan) + self.assertEqual(errors, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/resources/skills/product-one-pager/SKILL.md b/resources/skills/product-one-pager/SKILL.md new file mode 100644 index 0000000..5b2ddb5 --- /dev/null +++ b/resources/skills/product-one-pager/SKILL.md @@ -0,0 +1,57 @@ +--- +name: product-one-pager +version: 1.0.0 +description: | + 将已核验产品信息压缩为一页纸产品概览、宣传彩页或官网下载页文案。用于首次触达、 + 展会资料和销售跟进;不替代完整功能清单、技术规格或技术方案。 +allowed-tools: + - Read + - Grep + - Glob +compatibility: Markdown/HTML;建议配合 product-evidence +--- + +# 产品一页纸 + +## 必要输入 + +- 产品定位、目标受众和本次一页纸唯一目标。 +- 3 至 5 项核心能力、一个典型工作流、适用范围和限制。 +- 可公开的参数、案例、标识、截图和行动入口。 +- 页面尺寸、语言、品牌规范和发布渠道。 + +## 内容预算 + +一页纸不是把长文缩小字号。默认预算: + +- 主标题 1 个,副标题 1 段。 +- 目标问题 2 至 3 条。 +- 核心能力 3 至 5 项。 +- 工作方式或架构图 1 张。 +- 参数、案例或验证结果最多 3 项。 +- 部署与适用边界 1 个区域。 +- 行动入口 1 个。 + +超过预算时删减次要内容,不缩成无法阅读的小字。 + +## 生成流程 + +1. 用一句话回答“面向谁、提供什么、适用于什么范围”。 +2. 选择与目标受众最相关的能力,不按后台菜单罗列。 +3. 每项能力写“动作 + 结果”,避免形容词。 +4. 将必要条件和人工复核要求放在正文可见区域。 +5. 选择明确行动入口,例如项目咨询、申请演示或下载规格。 +6. 使用 `templates/product-one-pager.md` 起草,再进入视觉排版。 + +## 禁止事项 + +- 编造客户 Logo、案例、认证、排名或性能数字。 +- 使用“领先、唯一、全面、百分之百”等无依据绝对化表述。 +- 首屏写成实施教程、需求调查表或接口配置说明。 +- 将 `restricted`、`internal` 内容放入公开彩页。 +- 用二维码或短链掩盖未经审核的外部地址。 + +## 完成标准 + +打印或 100% 缩放时仍可读;读者在一分钟内能说出产品定位、核心能力、适用范围 +和下一步;所有数字和主张有证据;没有因压缩而删除关键限制。 diff --git a/resources/skills/product-one-pager/templates/product-one-pager.md b/resources/skills/product-one-pager/templates/product-one-pager.md new file mode 100644 index 0000000..f66a102 --- /dev/null +++ b/resources/skills/product-one-pager/templates/product-one-pager.md @@ -0,0 +1,37 @@ +# {{产品名称}} + +## {{面向目标角色,提供具体产品能力}} + +{{一句话说明产品类别、主要用途和适用范围。}} + +### 目标场景 + +- {{角色需要完成的具体任务}} +- {{现有流程中的可验证问题}} + +### 核心能力 + +| 能力 | 用户动作与结果 | +|---|---| +| {{能力一}} | {{动作;结果}} | +| {{能力二}} | {{动作;结果}} | +| {{能力三}} | {{动作;结果}} | + +### 工作方式 + +{{一张架构图、流程图或三步工作流。}} + +### 已核验参数或结果 + +- **{{数值}}**:{{指标、版本、条件与口径}} +- **{{数值}}**:{{指标、版本、条件与口径}} + +### 部署与适用范围 + +{{部署方式、数据条件、依赖、限制和人工复核要求。}} + +### 下一步 + +{{项目咨询 / 申请演示 / 获取技术规格}} + + diff --git a/resources/skills/product-presentation/SKILL.md b/resources/skills/product-presentation/SKILL.md new file mode 100644 index 0000000..43a2b2d --- /dev/null +++ b/resources/skills/product-presentation/SKILL.md @@ -0,0 +1,82 @@ +--- +name: product-presentation +version: 1.0.0 +description: | + 生成面向特定受众的产品介绍 PPT、逐页叙事和演讲备注。用于产品发布、客户宣讲、 + 售前交流或内部汇报;不负责现场产品操作步骤,操作型演示应使用 sales-demo-kit。 +allowed-tools: + - Read + - Grep + - Glob + - Execute +compatibility: 生成 PPTX 需 python-pptx 1.0+ +--- + +# 产品介绍 PPT + +## 必要输入 + +- 已核验的产品事实、功能、参数、案例授权和适用边界。 +- 受众角色、演示目标、场合、时长、页数和品牌要求。 +- 可用图片、架构图、截图及其授权范围。 + +先写一句演示目标,例如“让技术负责人理解部署边界并同意进入 POC”,再决定页序。 + +## 推荐叙事 + +按任务选择,不机械套用全部页面: + +1. 封面与本次议题。 +2. 受众当前面临的具体问题。 +3. 产品定位和适用范围。 +4. 核心工作流或架构。 +5. 关键能力,按场景组织,不按后台菜单朗读。 +6. 已核验参数、兼容性或安全边界。 +7. 获准公开的案例或验证结果。 +8. 部署、交付和下一步。 + +## 页面纪律 + +- 一页只有一个结论,标题直接写该页内容。 +- 正文优先 3 至 5 个要点,每个要点只表达一个信息。 +- 数字必须带条件和来源,不使用无依据的百分比。 +- 规划能力、试用能力和当前能力使用不同标识。 +- 讲稿可以补充上下文,但不能引入幻灯片中没有依据的新事实。 +- 不把产品介绍写成按钮操作手册,也不使用满页功能清单。 + +## 生成 PPTX + +先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux +优先使用 `python3`。下文 `` 表示探测成功的解释器命令。 + +复制并填写 `templates/deck.example.json`: + +```bash + -m pip install -r "/requirements.txt" + "/scripts/build_pptx.py" \ + --input ./build/deck.json \ + --output ./dist/product-presentation.pptx +``` + +`deck.json` 与配图是中间产物,放在 `build/`;交付物 PPTX 放在 `dist/`。复核用 +PDF 和页面截图一律写入 `build/check/`,不得与 PPTX 同目录。 + +构建器支持封面、章节页、要点页、双栏页、指标页、图片页和收尾页。它负责稳定 +排版,不负责补写内容;`deck.json` 中的文字必须先通过事实审查。 + +除章节页外每页必须提供非空 `notes`,否则构建失败。图片必须放在 `deck.json` +所在目录内,超过 40MB 或 8000 万像素会被拒绝;其余图片按版面尺寸和 150 DPI +重采样后嵌入,避免生成超大文件。 + +## 视觉复核 + +1. 使用 LibreOffice 或 PowerPoint 打开并导出 PDF;该 PDF 仅用于复核,交付物是 + PPTX,复核后应删除。LibreOffice 需安装 Impress 组件,仅装 Writer 时无法转换。 +2. 检查文字溢出、孤行、图片拉伸、低清截图和字号过小。 +3. 快速朗读全套讲稿,确认时间预算和页面转场自然。 +4. 核对所有数字、版本、案例名称和产品状态与事实清单一致。 + +## 完成标准 + +PPTX 可打开;页数和时长符合简报;每页目标明确;视觉层级统一;备注完整;无 +未经授权的客户信息、竞品结论或内部证据路径。 diff --git a/resources/skills/product-presentation/requirements.txt b/resources/skills/product-presentation/requirements.txt new file mode 100644 index 0000000..c3995dc --- /dev/null +++ b/resources/skills/product-presentation/requirements.txt @@ -0,0 +1 @@ +python-pptx>=1.0,<2 diff --git a/resources/skills/product-presentation/scripts/build_pptx.py b/resources/skills/product-presentation/scripts/build_pptx.py new file mode 100644 index 0000000..50a3b6c --- /dev/null +++ b/resources/skills/product-presentation/scripts/build_pptx.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +"""Build a 16:9 product presentation from a reviewed deck JSON file.""" + +import argparse +import json +import re +import tempfile +from pathlib import Path + +from PIL import Image, ImageOps +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR +from pptx.util import Inches, Pt + + +DEFAULT_THEME = { + "primary": "1F3864", + "accent": "C00000", + "background": "FFFFFF", + "text": "1A1A1A", + "muted": "666666", + "light": "F2F5F9", + "font_zh": "Microsoft YaHei", + "font_en": "Arial", +} +SLIDE_TYPES = { + "title", + "section", + "bullets", + "two-column", + "metrics", + "image", + "closing", +} +NOTES_OPTIONAL_TYPES = {"section"} +MAX_IMAGE_BYTES = 40 * 1024 * 1024 +MAX_IMAGE_PIXELS = 80_000_000 +RENDER_DPI = 150 + + +def color(value): + value = value.lstrip("#") + if not re.fullmatch(r"[0-9a-fA-F]{6}", value): + raise ValueError(f"颜色必须是六位十六进制值:{value!r}") + return RGBColor.from_string(value.upper()) + + +def nonempty(value, path): + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{path} 必须是非空字符串") + return value.strip() + + +def load_deck(path): + input_path = Path(path).expanduser().resolve() + with input_path.open(encoding="utf-8") as handle: + deck = json.load(handle) + if not isinstance(deck, dict): + raise ValueError("deck 根节点必须是对象") + nonempty(deck.get("title"), "title") + slides = deck.get("slides") + if not isinstance(slides, list) or not slides: + raise ValueError("slides 必须是非空数组") + theme = {**DEFAULT_THEME, **deck.get("theme", {})} + for key in ("primary", "accent", "background", "text", "muted", "light"): + color(theme[key]) + for index, slide in enumerate(slides): + validate_slide(slide, index) + deck["theme"] = theme + return input_path, deck + + +def validate_bullets(values, path, maximum=6): + if not isinstance(values, list) or not values: + raise ValueError(f"{path} 必须是非空数组") + if len(values) > maximum: + raise ValueError(f"{path} 最多 {maximum} 项") + for index, value in enumerate(values): + text = nonempty(value, f"{path}[{index}]") + if len(text) > 120: + raise ValueError(f"{path}[{index}] 超过 120 字") + + +def validate_slide(slide, index): + path = f"slides[{index}]" + if not isinstance(slide, dict): + raise ValueError(f"{path} 必须是对象") + slide_type = slide.get("type") + if slide_type not in SLIDE_TYPES: + raise ValueError(f"{path}.type 未知:{slide_type!r}") + nonempty(slide.get("title"), f"{path}.title") + if slide_type in {"bullets", "closing"}: + validate_bullets(slide.get("bullets"), f"{path}.bullets") + elif slide_type == "two-column": + for side in ("left", "right"): + column = slide.get(side) + if not isinstance(column, dict): + raise ValueError(f"{path}.{side} 必须是对象") + nonempty(column.get("title"), f"{path}.{side}.title") + validate_bullets( + column.get("bullets"), + f"{path}.{side}.bullets", + maximum=5, + ) + elif slide_type == "metrics": + metrics = slide.get("metrics") + if not isinstance(metrics, list) or not 1 <= len(metrics) <= 4: + raise ValueError(f"{path}.metrics 必须包含 1 至 4 项") + for metric_index, metric in enumerate(metrics): + if not isinstance(metric, dict): + raise ValueError( + f"{path}.metrics[{metric_index}] 必须是对象" + ) + for field in ("value", "label", "detail"): + nonempty( + metric.get(field), + f"{path}.metrics[{metric_index}].{field}", + ) + elif slide_type == "image": + nonempty(slide.get("image"), f"{path}.image") + if slide_type not in NOTES_OPTIONAL_TYPES: + nonempty(slide.get("notes"), f"{path}.notes") + + +def add_run_font(run, theme, size, bold=False, color_value=None): + run.font.name = theme["font_en"] + run.font.size = Pt(size) + run.font.bold = bold + run.font.color.rgb = color(color_value or theme["text"]) + run.font._element.set("lang", "zh-CN") + + +def add_textbox( + slide, + theme, + x, + y, + width, + height, + text="", + size=24, + bold=False, + color_value=None, + align=PP_ALIGN.LEFT, + vertical=MSO_ANCHOR.TOP, + margin=0.08, +): + shape = slide.shapes.add_textbox( + Inches(x), + Inches(y), + Inches(width), + Inches(height), + ) + frame = shape.text_frame + frame.clear() + frame.margin_left = Inches(margin) + frame.margin_right = Inches(margin) + frame.margin_top = Inches(margin) + frame.margin_bottom = Inches(margin) + frame.vertical_anchor = vertical + paragraph = frame.paragraphs[0] + paragraph.alignment = align + run = paragraph.add_run() + run.text = text + add_run_font(run, theme, size, bold=bold, color_value=color_value) + return shape + + +def set_background(slide, theme, key="background"): + fill = slide.background.fill + fill.solid() + fill.fore_color.rgb = color(theme[key]) + + +def add_header(slide, theme, title): + add_textbox( + slide, + theme, + 0.65, + 0.35, + 11.9, + 0.65, + title, + size=26, + bold=True, + color_value=theme["primary"], + ) + line = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + Inches(0.65), + Inches(1.07), + Inches(1.15), + Inches(0.06), + ) + line.fill.solid() + line.fill.fore_color.rgb = color(theme["accent"]) + line.line.fill.background() + + +def add_footer(slide, deck, number): + footer = deck.get("footer", "") + theme = deck["theme"] + if footer: + add_textbox( + slide, + theme, + 0.65, + 7.08, + 10.8, + 0.22, + footer, + size=8.5, + color_value=theme["muted"], + ) + add_textbox( + slide, + theme, + 12.0, + 7.02, + 0.55, + 0.25, + str(number), + size=9, + color_value=theme["muted"], + align=PP_ALIGN.RIGHT, + ) + + +def add_bullet_frame(slide, theme, x, y, width, height, bullets, size=21): + shape = slide.shapes.add_textbox( + Inches(x), + Inches(y), + Inches(width), + Inches(height), + ) + frame = shape.text_frame + frame.clear() + frame.word_wrap = True + frame.margin_left = Inches(0.12) + frame.margin_right = Inches(0.08) + for index, item in enumerate(bullets): + paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph() + paragraph.text = item + paragraph.level = 0 + paragraph.space_after = Pt(12) + paragraph.line_spacing = 1.12 + paragraph.font.size = Pt(size) + paragraph.font.name = theme["font_en"] + paragraph.font.color.rgb = color(theme["text"]) + return shape + + +def add_notes(slide, notes): + if not notes: + return + notes_frame = slide.notes_slide.notes_text_frame + notes_frame.text = notes + + +def render_title(slide, deck, item): + theme = deck["theme"] + set_background(slide, theme, "primary") + add_textbox( + slide, + theme, + 0.9, + 1.75, + 11.5, + 1.3, + item["title"], + size=34, + bold=True, + color_value="FFFFFF", + vertical=MSO_ANCHOR.MIDDLE, + ) + subtitle = item.get("subtitle", deck.get("subtitle", "")) + if subtitle: + add_textbox( + slide, + theme, + 0.95, + 3.2, + 10.8, + 0.8, + subtitle, + size=20, + color_value="DCE6F1", + ) + author = deck.get("author", "") + if author: + add_textbox( + slide, + theme, + 0.95, + 6.35, + 10.0, + 0.35, + author, + size=12, + color_value="DCE6F1", + ) + + +def render_section(slide, deck, item): + theme = deck["theme"] + set_background(slide, theme, "light") + add_textbox( + slide, + theme, + 1.0, + 2.2, + 11.2, + 1.0, + item["title"], + size=32, + bold=True, + color_value=theme["primary"], + align=PP_ALIGN.CENTER, + vertical=MSO_ANCHOR.MIDDLE, + ) + if item.get("subtitle"): + add_textbox( + slide, + theme, + 1.5, + 3.35, + 10.2, + 0.7, + item["subtitle"], + size=18, + color_value=theme["muted"], + align=PP_ALIGN.CENTER, + ) + + +def render_bullets(slide, deck, item): + theme = deck["theme"] + set_background(slide, theme) + add_header(slide, theme, item["title"]) + add_bullet_frame(slide, theme, 0.9, 1.45, 11.5, 5.2, item["bullets"]) + + +def render_two_column(slide, deck, item): + theme = deck["theme"] + set_background(slide, theme) + add_header(slide, theme, item["title"]) + for x, column in ((0.75, item["left"]), (6.78, item["right"])): + panel = slide.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE, + Inches(x), + Inches(1.55), + Inches(5.55), + Inches(4.95), + ) + panel.fill.solid() + panel.fill.fore_color.rgb = color(theme["light"]) + panel.line.color.rgb = color("D8E0EA") + add_textbox( + slide, + theme, + x + 0.3, + 1.8, + 4.95, + 0.5, + column["title"], + size=20, + bold=True, + color_value=theme["primary"], + ) + add_bullet_frame( + slide, + theme, + x + 0.25, + 2.5, + 5.0, + 3.55, + column["bullets"], + size=17, + ) + + +def render_metrics(slide, deck, item): + theme = deck["theme"] + set_background(slide, theme) + add_header(slide, theme, item["title"]) + metrics = item["metrics"] + gap = 0.25 + total_width = 11.8 + width = (total_width - gap * (len(metrics) - 1)) / len(metrics) + for index, metric in enumerate(metrics): + x = 0.75 + index * (width + gap) + panel = slide.shapes.add_shape( + MSO_SHAPE.ROUNDED_RECTANGLE, + Inches(x), + Inches(1.75), + Inches(width), + Inches(4.45), + ) + panel.fill.solid() + panel.fill.fore_color.rgb = color(theme["light"]) + panel.line.color.rgb = color("D8E0EA") + add_textbox( + slide, + theme, + x + 0.15, + 2.05, + width - 0.3, + 1.0, + metric["value"], + size=29, + bold=True, + color_value=theme["accent"], + align=PP_ALIGN.CENTER, + vertical=MSO_ANCHOR.MIDDLE, + ) + add_textbox( + slide, + theme, + x + 0.15, + 3.15, + width - 0.3, + 0.6, + metric["label"], + size=17, + bold=True, + color_value=theme["primary"], + align=PP_ALIGN.CENTER, + ) + add_textbox( + slide, + theme, + x + 0.2, + 4.0, + width - 0.4, + 1.45, + metric["detail"], + size=12, + color_value=theme["muted"], + align=PP_ALIGN.CENTER, + ) + + +def prepare_image(image_path, max_width_in, max_height_in, staging_dir): + """Normalize orientation and cap pixels so decks stay a usable size.""" + if image_path.stat().st_size > MAX_IMAGE_BYTES: + raise ValueError( + f"图片超过 {MAX_IMAGE_BYTES // (1024 * 1024)}MB:{image_path.name}" + ) + with Image.open(image_path) as image: + if image.width * image.height > MAX_IMAGE_PIXELS: + raise ValueError(f"图片像素数过大:{image_path.name}") + image = ImageOps.exif_transpose(image) + width, height = image.size + scale = min(max_width_in / width, max_height_in / height) + target = ( + max(1, round(width * scale * RENDER_DPI)), + max(1, round(height * scale * RENDER_DPI)), + ) + if target[0] >= width and target[1] >= height: + if image_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif"}: + return image_path, width, height + target = (width, height) + resized = image.resize(target, Image.LANCZOS) + if resized.mode not in ("RGB", "RGBA", "L"): + resized = resized.convert("RGBA") + staged = staging_dir / f"{image_path.stem}-{target[0]}x{target[1]}.png" + resized.save(staged, format="PNG", optimize=True) + return staged, width, height + + +def render_image(slide, deck, item, base_dir, staging_dir): + theme = deck["theme"] + set_background(slide, theme) + add_header(slide, theme, item["title"]) + image_path = (base_dir / item["image"]).resolve() + if base_dir != image_path and base_dir not in image_path.parents: + raise ValueError(f"图片必须位于 deck.json 目录内:{image_path}") + if not image_path.is_file(): + raise FileNotFoundError(f"图片不存在:{image_path}") + max_width, max_height = 11.5, 5.35 + source, width, height = prepare_image( + image_path, max_width, max_height, staging_dir + ) + scale = min(max_width / width, max_height / height) + draw_width, draw_height = width * scale, height * scale + slide.shapes.add_picture( + str(source), + Inches((13.333 - draw_width) / 2), + Inches(1.35 + (5.35 - draw_height) / 2), + width=Inches(draw_width), + height=Inches(draw_height), + ) + if item.get("caption"): + add_textbox( + slide, + theme, + 1.0, + 6.55, + 11.3, + 0.3, + item["caption"], + size=10, + color_value=theme["muted"], + align=PP_ALIGN.CENTER, + ) + + +def render_closing(slide, deck, item): + theme = deck["theme"] + set_background(slide, theme, "primary") + add_textbox( + slide, + theme, + 0.9, + 1.2, + 11.5, + 0.9, + item["title"], + size=32, + bold=True, + color_value="FFFFFF", + align=PP_ALIGN.CENTER, + ) + add_bullet_frame( + slide, + {**theme, "text": "FFFFFF"}, + 2.0, + 2.55, + 9.3, + 3.2, + item["bullets"], + size=20, + ) + + +RENDERERS = { + "title": render_title, + "section": render_section, + "bullets": render_bullets, + "two-column": render_two_column, + "metrics": render_metrics, + "closing": render_closing, +} + + +def build_presentation(input_path, deck, output_path): + output = Path(output_path).expanduser().resolve() + if output.suffix.lower() != ".pptx": + raise ValueError("输出必须使用 .pptx 扩展名") + if output == input_path: + raise ValueError("输出不能覆盖 deck.json") + output.parent.mkdir(parents=True, exist_ok=True) + + presentation = Presentation() + presentation.slide_width = Inches(13.333) + presentation.slide_height = Inches(7.5) + blank = presentation.slide_layouts[6] + with tempfile.TemporaryDirectory(prefix="deck-images-") as staging: + staging_dir = Path(staging) + for index, item in enumerate(deck["slides"], 1): + slide = presentation.slides.add_slide(blank) + if item["type"] == "image": + render_image(slide, deck, item, input_path.parent, staging_dir) + else: + RENDERERS[item["type"]](slide, deck, item) + if item["type"] != "title": + add_footer(slide, deck, index) + add_notes(slide, item.get("notes", "")) + presentation.save(output) + return output + + +def parse_args(): + parser = argparse.ArgumentParser(description="从 deck.json 生成产品介绍 PPTX") + parser.add_argument("--input", required=True, help="deck.json") + parser.add_argument("--output", required=True, help="输出 .pptx") + return parser.parse_args() + + +def main(): + args = parse_args() + input_path, deck = load_deck(args.input) + output = build_presentation(input_path, deck, args.output) + print(f"saved: {output}") + + +if __name__ == "__main__": + main() diff --git a/resources/skills/product-presentation/templates/deck.example.json b/resources/skills/product-presentation/templates/deck.example.json new file mode 100644 index 0000000..7f555f0 --- /dev/null +++ b/resources/skills/product-presentation/templates/deck.example.json @@ -0,0 +1,80 @@ +{ + "title": "示例产品介绍", + "subtitle": "面向目标受众的产品说明", + "author": "产品团队", + "footer": "示例产品", + "theme": { + "primary": "1F3864", + "accent": "C00000", + "background": "FFFFFF", + "text": "1A1A1A", + "font_zh": "Microsoft YaHei", + "font_en": "Arial" + }, + "slides": [ + { + "type": "title", + "title": "示例产品介绍", + "subtitle": "面向目标受众的产品说明", + "notes": "说明本次交流目标和议程。" + }, + { + "type": "bullets", + "title": "目标场景与问题", + "bullets": [ + "说明目标角色当前需要完成的任务", + "说明现有流程中的可验证问题", + "说明本次介绍覆盖和不覆盖的范围" + ], + "notes": "不要使用宏大行业背景替代具体问题。", + "evidence_ids": [ + "CLM-001" + ] + }, + { + "type": "two-column", + "title": "产品工作方式", + "left": { + "title": "输入与条件", + "bullets": [ + "部署环境", + "数据与接口条件" + ] + }, + "right": { + "title": "动作与结果", + "bullets": [ + "用户触发的具体动作", + "系统产生的可观察结果" + ] + }, + "notes": "结合当前版本说明工作流。" + }, + { + "type": "metrics", + "title": "已核验指标", + "metrics": [ + { + "value": "参数值", + "label": "指标名称", + "detail": "版本、条件和统计口径" + }, + { + "value": "参数值", + "label": "指标名称", + "detail": "版本、条件和统计口径" + } + ], + "notes": "只使用事实清单中有证据的指标。" + }, + { + "type": "closing", + "title": "下一步", + "bullets": [ + "确认适用场景和边界", + "确定验证材料与责任人" + ], + "notes": "给出明确、可执行的后续动作。" + } + ] +} diff --git a/resources/skills/product-presentation/tests/test_build_pptx.py b/resources/skills/product-presentation/tests/test_build_pptx.py new file mode 100644 index 0000000..ea68d5d --- /dev/null +++ b/resources/skills/product-presentation/tests/test_build_pptx.py @@ -0,0 +1,147 @@ +import copy +import importlib.util +import io +import json +import tempfile +import unittest +from pathlib import Path + +from PIL import Image +from pptx import Presentation + + +SKILL_DIR = Path(__file__).resolve().parents[1] +SCRIPT = SKILL_DIR / "scripts" / "build_pptx.py" +SPEC = importlib.util.spec_from_file_location("build_pptx", SCRIPT) +build_pptx = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(build_pptx) + + +class BuildPptxTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + template = SKILL_DIR / "templates" / "deck.example.json" + cls.deck = json.loads(template.read_text(encoding="utf-8")) + + def test_builds_example_deck(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + input_path = root / "deck.json" + input_path.write_text( + json.dumps(self.deck, ensure_ascii=False), + encoding="utf-8", + ) + loaded_path, deck = build_pptx.load_deck(input_path) + output = build_pptx.build_presentation( + loaded_path, + deck, + root / "deck.pptx", + ) + presentation = Presentation(output) + self.assertEqual(len(presentation.slides), len(self.deck["slides"])) + all_text = "\n".join( + shape.text + for slide in presentation.slides + for shape in slide.shapes + if hasattr(shape, "text") + ) + self.assertIn("目标场景与问题", all_text) + self.assertIn("下一步", all_text) + for slide, item in zip(presentation.slides, self.deck["slides"]): + self.assertEqual( + slide.notes_slide.notes_text_frame.text.strip(), + item["notes"].strip(), + ) + + def test_requires_notes_outside_section_slides(self): + deck = copy.deepcopy(self.deck) + deck["slides"][1].pop("notes") + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "deck.json" + path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8") + with self.assertRaisesRegex(ValueError, r"slides\[1\]\.notes"): + build_pptx.load_deck(path) + + def test_downsamples_large_image_slide(self): + deck = copy.deepcopy(self.deck) + deck["slides"].insert( + 3, + { + "type": "image", + "title": "参考架构", + "image": "architecture.png", + "caption": "示例架构图", + "notes": "说明组件边界。", + }, + ) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + Image.new("RGB", (5000, 3000), "white").save(root / "architecture.png") + path = root / "deck.json" + path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8") + loaded_path, loaded = build_pptx.load_deck(path) + output = build_pptx.build_presentation( + loaded_path, loaded, root / "deck.pptx" + ) + presentation = Presentation(output) + self.assertEqual(len(presentation.slides), len(deck["slides"])) + pictures = [ + shape + for slide in presentation.slides + for shape in slide.shapes + if shape.shape_type == 13 + ] + self.assertEqual(len(pictures), 1) + with Image.open(io.BytesIO(pictures[0].image.blob)) as embedded: + self.assertLess(embedded.width, 5000) + + def test_rejects_image_outside_deck_directory(self): + deck = copy.deepcopy(self.deck) + deck["slides"].insert( + 1, + { + "type": "image", + "title": "外部图片", + "image": "../outside.png", + "notes": "越界图片。", + }, + ) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "deck" + root.mkdir() + Image.new("RGB", (10, 10), "white").save(Path(tmp) / "outside.png") + path = root / "deck.json" + path.write_text(json.dumps(deck, ensure_ascii=False), encoding="utf-8") + loaded_path, loaded = build_pptx.load_deck(path) + with self.assertRaisesRegex(ValueError, "必须位于"): + build_pptx.build_presentation( + loaded_path, loaded, root / "deck.pptx" + ) + + def test_rejects_too_many_bullets(self): + deck = copy.deepcopy(self.deck) + deck["slides"][1]["bullets"] = [f"项目 {index}" for index in range(7)] + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "deck.json" + path.write_text( + json.dumps(deck, ensure_ascii=False), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "最多 6 项"): + build_pptx.load_deck(path) + + def test_rejects_unknown_slide_type(self): + deck = copy.deepcopy(self.deck) + deck["slides"][0]["type"] = "unknown" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "deck.json" + path.write_text( + json.dumps(deck, ensure_ascii=False), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "未知"): + build_pptx.load_deck(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/resources/skills/project-planning/SKILL.md b/resources/skills/project-planning/SKILL.md deleted file mode 100644 index c7e5a20..0000000 --- a/resources/skills/project-planning/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -id: project-planning -name: 项目规划 -description: 将项目目标拆解为范围、里程碑、任务、责任、风险与验收标准,形成可执行计划。 -version: 1.0.0 -tags: - - 项目 - - 计划 - - 管理 ---- - -# 项目规划 - -## 工作原则 - -- 先明确目标、成功标准、范围边界、约束和关键相关方。 -- 不虚构资源、工期、预算或团队承诺。 -- 计划应体现任务依赖、决策节点和必要缓冲。 -- 风险需描述触发条件、影响、负责人和应对措施。 - -## 规划流程 - -1. 将项目目标转化为可验收的交付物。 -2. 明确范围内事项、范围外事项和关键假设。 -3. 拆解阶段、里程碑、任务及其依赖关系。 -4. 为任务指定负责人、时间和完成标准(如已知)。 -5. 评估风险、资源缺口、沟通机制和变更方式。 - -## 输出结构 - -- **项目概述:** 背景、目标与成功标准 -- **范围:** 包含、不包含与假设 -- **里程碑:** 交付物、目标日期与验收标准 -- **任务计划:** 任务、负责人、依赖、时间与状态 -- **风险登记:** 风险、概率、影响与应对 -- **治理机制:** 汇报节奏、决策人与变更流程 - -未知信息标注“待确认”,并说明其对计划可靠性的影响。 diff --git a/resources/skills/proofreading/SKILL.md b/resources/skills/proofreading/SKILL.md deleted file mode 100644 index 93cc95a..0000000 --- a/resources/skills/proofreading/SKILL.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -id: proofreading -name: 文本校对 -description: 系统检查文本的错别字、语法、标点、格式与一致性,并在不改变原意的前提下提出修订。 -version: 1.0.0 -tags: - - 校对 - - 编辑 - - 质量 ---- - -# 文本校对 - -## 工作原则 - -- 以保留作者原意、事实和语气为首要目标。 -- 区分确定错误、风格建议和需要作者确认的内容。 -- 不擅自改动数字、专有名词、引用、承诺或结论。 -- 修改应保持全文术语、格式和标点规则一致。 - -## 校对流程 - -1. 确认文本用途、目标读者和采用的语言规范。 -2. 检查错别字、语法、搭配、标点和病句。 -3. 检查标题层级、编号、空格、日期与数字格式。 -4. 检查术语、人名、缩写和指代的一致性。 -5. 复核修改是否引入新歧义或改变原意。 - -## 输出方式 - -- **清洁版:** 已修正明确错误的完整文本。 -- **修改说明:** 汇总影响含义或结构的主要调整。 -- **待确认项:** 列出歧义、事实疑点或多种可接受写法。 - -纯风格调整应克制;如用户只要求找错,不主动重写全文。 diff --git a/resources/skills/requirements-analysis/SKILL.md b/resources/skills/requirements-analysis/SKILL.md deleted file mode 100644 index 78f143a..0000000 --- a/resources/skills/requirements-analysis/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -id: requirements-analysis -name: 需求分析 -description: 将业务诉求整理为边界明确、可验证、可追踪的需求,识别歧义、依赖与验收条件。 -version: 1.0.0 -tags: - - 需求 - - 分析 - - 验收 ---- - -# 需求分析 - -## 工作原则 - -- 区分业务目标、用户问题、解决方案设想和正式需求。 -- 不替相关方决定未确认的优先级、范围或业务规则。 -- 每项需求应明确对象、触发条件、预期行为和验收结果。 -- 主动识别歧义、冲突、异常场景、依赖与非功能要求。 - -## 分析流程 - -1. 明确目标用户、业务目标和衡量成功的指标。 -2. 梳理现状、痛点、范围边界与关键术语。 -3. 将诉求拆成独立、可验证的功能需求。 -4. 补充权限、数据、性能、可用性和合规等约束。 -5. 定义验收标准,并建立需求与目标的对应关系。 - -## 输出结构 - -- **背景与目标:** 问题、用户与预期价值 -- **范围:** 包含、不包含与假设 -- **功能需求:** 编号、描述、优先级与依赖 -- **业务规则:** 条件、例外与边界 -- **非功能需求:** 质量属性与约束 -- **验收标准:** 可观察、可判断的结果 -- **待确认问题:** 歧义、冲突与决策人 - -所有推断均标注为“假设”,未经确认不得写成既定要求。 diff --git a/resources/skills/research-synthesis/SKILL.md b/resources/skills/research-synthesis/SKILL.md deleted file mode 100644 index 74512cd..0000000 --- a/resources/skills/research-synthesis/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -id: research-synthesis -name: 研究综合 -description: 综合用户提供的研究材料,比较观点与证据,形成可追溯、平衡且边界清晰的结论。 -version: 1.0.0 -tags: - - 研究 - - 综合 - - 证据 ---- - -# 研究综合 - -## 工作原则 - -- 仅综合用户提供的材料,不声称查阅了未提供的信息。 -- 清楚区分材料中的事实、作者观点、推论和自身归纳。 -- 保留来源标识,使关键结论可追溯到具体材料。 -- 同时呈现一致观点、分歧、证据缺口和适用边界。 - -## 综合流程 - -1. 明确研究问题、范围和评价标准。 -2. 按主题整理各材料的主张、证据与方法。 -3. 比较一致性、冲突点、证据强弱和时间适用性。 -4. 提炼跨材料模式,并检查是否存在反例。 -5. 形成有限度的结论及进一步研究问题。 - -## 输出结构 - -- **研究问题:** 范围与目标 -- **材料概览:** 每份材料的主题与证据类型 -- **主题综合:** 共识、差异与关联 -- **证据评估:** 强项、局限与潜在偏差 -- **综合结论:** 结论、置信边界与适用条件 -- **待研究问题:** 现有材料无法回答的事项 - -引用或转述时保留用户材料中的来源名称,不伪造出处。 diff --git a/resources/skills/sales-demo-kit/SKILL.md b/resources/skills/sales-demo-kit/SKILL.md new file mode 100644 index 0000000..347e8ad --- /dev/null +++ b/resources/skills/sales-demo-kit/SKILL.md @@ -0,0 +1,58 @@ +--- +name: sales-demo-kit +version: 1.0.0 +description: | + 生成可执行的产品演示故事线、环境清单、操作脚本、讲解词、失败回退和演练检查表。 + 用于售前 Demo、POC 汇报或验收演示;不负责制作通用产品介绍 PPT。 +allowed-tools: + - Read + - Grep + - Glob + - Execute +compatibility: Markdown/JSON;演示自动化需另行使用可用的控制工具 +--- + +# 售前演示套件 + +## 必要输入 + +- 演示目标、受众、时长和希望获得的下一步。 +- 可用产品环境、版本、账号角色、测试数据和网络限制。 +- 允许展示的功能、参数、客户数据和日志范围。 +- 已知不稳定点、恢复方法和备用材料。 + +不得把真实密码、令牌、私有主机名或客户数据写进演示包。 + +## 设计演示 + +1. 选择一个完整用户任务作为主线,不按菜单逐页点功能。 +2. 为每一步写前置状态、操作、预期结果、讲解重点和事实 ID。 +3. 明确哪些结果实时产生,哪些是预置数据或截图。 +4. 为外部依赖、模型不稳定、网络异常和数据污染准备回退。 +5. 定义演示结束后的环境重置步骤。 +6. 将可选深挖问题放入问答分支,不打断主流程时间预算。 + +## 输出 + +复制 `templates/demo-kit.md`,形成: + +- 演示故事线和时间分配。 +- 环境、账号角色和测试数据清单。 +- 逐步运行手册。 +- 讲解词与常见问答。 +- 失败回退、备用截图和重置步骤。 +- 演练与现场检查表。 + +## 演练门禁 + +- 在与现场相同版本和权限下完整跑通至少一次。 +- 每一步的预期结果可观察、可截图、可恢复。 +- 不依赖浏览器历史、个人缓存或未记录的人工准备。 +- 所有示例数据可公开或已匿名化。 +- 规划能力不得通过预制截图伪装成实时功能。 +- 讲解数字和产品介绍 PPT、技术方案保持一致。 + +## 完成标准 + +演示能在时间预算内重复执行;关键步骤有备用路径;失败不会暴露敏感信息或破坏 +环境;操作脚本、讲解词和事实证据一致;现场人员知道何时停止、切换备用和重置。 diff --git a/resources/skills/sales-demo-kit/templates/demo-kit.md b/resources/skills/sales-demo-kit/templates/demo-kit.md new file mode 100644 index 0000000..c1ac0fe --- /dev/null +++ b/resources/skills/sales-demo-kit/templates/demo-kit.md @@ -0,0 +1,49 @@ +# {{产品/场景}}演示套件 + +## 一、演示目标 + +- 受众:{{角色}} +- 目标:{{希望受众理解或同意的事项}} +- 时长:{{分钟}} +- 产品版本:{{版本}} +- 不展示范围:{{范围}} + +## 二、环境与数据 + +| 项目 | 要求 | 检查方法 | 状态 | +|---|---|---|---| +| 演示环境 | {{环境,不写真实密钥}} | {{检查命令或页面}} | 待检查 | +| 账号角色 | {{角色}} | {{权限确认}} | 待检查 | +| 示例数据 | {{匿名数据集}} | {{完整性检查}} | 待检查 | + +## 三、演示故事线 + +| 时间 | 阶段 | 目的 | 讲解重点 | +|---:|---|---|---| +| 0-2 分钟 | 场景说明 | {{目的}} | {{重点}} | + +## 四、逐步运行手册 + +### STEP-01 {{步骤名称}} + +- 前置状态:{{状态}} +- 操作:{{具体动作}} +- 预期结果:{{可观察结果}} +- 事实依据:{{FEAT/CLM ID}} +- 讲解词:{{简短讲解}} +- 失败判断:{{何时判定失败}} +- 回退:{{备用页面、截图或替代步骤}} + +## 五、常见问答 + +| 问题 | 回答要点 | 依据 | 不应承诺 | +|---|---|---|---| +| {{问题}} | {{回答}} | {{证据 ID}} | {{边界}} | + +## 六、重置与现场检查 + +- [ ] 清理上次演示数据。 +- [ ] 恢复初始账号和权限。 +- [ ] 验证网络、依赖和备用材料。 +- [ ] 确认屏幕无通知、密钥和客户信息。 +- [ ] 完整计时演练并记录问题。 diff --git a/resources/skills/solution-whitepaper/SKILL.md b/resources/skills/solution-whitepaper/SKILL.md new file mode 100644 index 0000000..35558a4 --- /dev/null +++ b/resources/skills/solution-whitepaper/SKILL.md @@ -0,0 +1,62 @@ +--- +name: solution-whitepaper +version: 1.0.0 +description: | + 编写解释行业问题、技术原理、参考架构、实现方法、测试证据和适用边界的产品或 + 解决方案白皮书。用于技术传播和决策评估;不编制客户项目计划,也不把宣传口号 + 当作技术论证。 +allowed-tools: + - Read + - Grep + - Glob + - Execute +compatibility: Markdown;可配合 longdoc-docx 导出 Word(PDF 仅用于核验) +--- + +# 解决方案白皮书 + +## 必要输入 + +- 白皮书主题、目标读者、研究问题和发布范围。 +- 产品事实、技术来源、测试报告、标准和第三方参考。 +- 可公开的架构图、数据、案例和限制。 +- 引用格式、目标篇幅和评审要求。 + +## 论证结构 + +1. 明确问题范围,不用泛化行业背景凑篇幅。 +2. 定义术语、对象和评价标准。 +3. 解释方法、原理和参考架构。 +4. 用产品实现或参考流程说明方法如何落地。 +5. 给出测试方法、条件、结果和不确定性。 +6. 说明安全、部署、治理和人工复核边界。 +7. 总结适用场景、限制和后续研究,不做空洞升华。 + +使用 `templates/solution-whitepaper.md` 建立章节。 + +## 证据规则 + +- 标准、论文、第三方观点和产品事实分开引用。 +- 指标必须说明样本、版本、环境、周期和计算方法。 +- 实测结果、设计目标和规划能力使用不同标签。 +- 无法访问原始来源时标记二手来源,不把摘要转述成原始结论。 +- 第三方图表必须检查许可并保留出处。 +- 参考文献编号、正文引用和图表来源必须一一对应。 + +## 写作规则 + +- 标题说明对象或结论范围,不写“我们如何理解”“结果说明了什么”。 +- 先给定义和条件,再给结论。 +- 架构章节解释边界与数据流,不罗列产品菜单。 +- 限制章节必须保留,不能在营销审校时被删除。 +- 技术白皮书可以有观点,但必须区分事实、推断和建议。 + +## 导出 + +如已安装 `deai-writing`,在最终通读前扫描中文套路表达;如已安装 +`longdoc-docx`,用其生成 DOCX 并借助临时 PDF 执行高分辨率视觉复核。 + +## 完成标准 + +研究问题得到回答;术语统一;关键结论有来源;测试可复核;架构图与正文一致; +限制、依赖和适用范围完整;参考文献无缺失、重复或无法定位条目。 diff --git a/resources/skills/solution-whitepaper/templates/solution-whitepaper.md b/resources/skills/solution-whitepaper/templates/solution-whitepaper.md new file mode 100644 index 0000000..17a3446 --- /dev/null +++ b/resources/skills/solution-whitepaper/templates/solution-whitepaper.md @@ -0,0 +1,42 @@ +# {{白皮书标题}} + +## 摘要 + +{{研究对象、问题、方法、主要结论和适用边界。}} + +## 1. 问题范围与目标读者 + +## 2. 术语、对象与评价标准 + +| 术语 | 定义 | 范围 | +|---|---|---| +| {{术语}} | {{定义}} | {{适用范围}} | + +## 3. 方法与技术原理 + +## 4. 参考架构与关键数据流 + +## 5. 产品实现与典型工作流 + +## 6. 测试方法与结果 + +### 6.1 测试环境 +### 6.2 数据、样本与统计口径 +### 6.3 测试结果 +### 6.4 不确定性与结果解释 + +## 7. 安全、治理与部署考虑 + +## 8. 适用场景与限制 + +## 9. 结论 + +## 参考文献 + +1. {{作者/机构}},《{{标题}}》,{{版本或日期}},{{来源定位}}。 + +## 内部主张追溯 + +| 章节 | 主张 ID | 证据 ID | 公开级别 | 复核 | +|---|---|---|---|---| +| {{章节}} | {{CLM-001}} | {{EVD-001}} | public | 通过 | diff --git a/resources/skills/spreadsheet-analysis/SKILL.md b/resources/skills/spreadsheet-analysis/SKILL.md deleted file mode 100644 index 92fa2b7..0000000 --- a/resources/skills/spreadsheet-analysis/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -id: spreadsheet-analysis -name: 表格分析 -description: 基于用户提供的表格内容规划分析方法,识别数据质量问题并形成可解释的业务结论。 -version: 1.0.0 -tags: - - 表格 - - 数据分析 - - 洞察 ---- - -# 表格分析 - -## 工作原则 - -- 先确认分析目标、字段含义、时间范围、单位和统计口径。 -- 不猜测缺失值、异常值或字段关系,不将相关性表述为因果性。 -- 明确区分原始数据、计算结果、解释和建议。 -- 涉及个人或敏感数据时,建议最小化使用并进行脱敏。 - -## 分析流程 - -1. 盘点工作表、字段、数据类型与记录范围。 -2. 检查缺失、重复、异常、口径冲突和格式不一致。 -3. 根据问题选择汇总、分组、对比、趋势或分布分析。 -4. 记录计算定义、筛选条件和必要假设。 -5. 提炼证据充分的发现、局限与后续验证建议。 - -## 输出结构 - -- **分析目标:** 要回答的业务问题 -- **数据概况:** 范围、字段、口径与质量 -- **分析方法:** 分组维度、指标定义与假设 -- **关键发现:** 结论及对应证据 -- **限制与风险:** 数据不足或偏差来源 -- **建议:** 可验证、可执行的下一步 - -对无法从现有数据支持的结论,应明确说明“证据不足”。 diff --git a/resources/skills/technical-proposal/SKILL.md b/resources/skills/technical-proposal/SKILL.md new file mode 100644 index 0000000..cc31820 --- /dev/null +++ b/resources/skills/technical-proposal/SKILL.md @@ -0,0 +1,74 @@ +--- +name: technical-proposal +version: 1.0.0 +description: | + 基于客户需求、产品事实和项目约束编制完整技术方案,覆盖需求分析、总体架构、 + 详细设计、实施、交付、质量、安全、风险和验收。用于投标技术方案或客户解决 + 方案;不负责制定招标参数,也不替代逐条招标响应矩阵。 +allowed-tools: + - Read + - Grep + - Glob + - Execute +compatibility: Markdown;可配合 longdoc-docx 导出 Word(PDF 仅用于核验) +--- + +# 技术方案 + +## 必要输入 + +- 客户需求原文及编号、评分点或验收目标。 +- 产品事实与证据、功能状态、参数和限制。 +- 部署环境、现有系统、接口、数据、安全和合规约束。 +- 项目范围、责任边界、计划、交付物和非范围项。 + +不确定内容进入“假设与待确认事项”,不能默认为客户已具备或产品已支持。 + +## 编制顺序 + +1. 建立需求追溯表,为每项需求分配稳定编号。招标场景下 + `tender-response-matrix` 是需求编号、响应状态和偏离结论的唯一权威来源, + 本技能只派生视图,不得另建一套编号或改写其状态。 +2. 区分业务目标、功能需求、非功能需求、接口约束和验收要求。 +3. 先确定范围、假设和总体架构,再展开模块设计。 +4. 对每个设计说明采用的产品能力、依赖条件和限制。 +5. 将设计落实到实施任务、交付物、质量措施和验收方法。 +6. 最后编制摘要,不能先写宣传性摘要再反推正文。 + +## 章节建议 + +复制 `templates/technical-proposal.md`,按项目裁剪: + +- 方案摘要 +- 项目理解与需求分析 +- 范围、假设与责任边界 +- 总体技术架构与数据流 +- 详细功能和接口设计 +- 部署、安全、性能和运维设计 +- 实施计划、组织和质量保证 +- 交付物、培训和知识转移 +- 验收方法、风险和偏离说明 +- 需求追溯矩阵 + +## 写作门禁 + +- 需求 → 设计 → 产品能力 → 交付物 → 验收方法必须可追溯。 +- 规划能力必须使用将来时,并说明是否属于本项目交付范围。 +- 架构图与正文必须使用相同组件名称和边界。 +- 不把客户责任、第三方依赖或人工复核要求隐藏在脚注中。 +- 不编造团队人数、工期、性能、案例、资质或承诺。 +- 方案正文以系统和动作陈述为主,减少“我方/我们”堆叠。 + +## 导出与审校 + +如已安装相关技能: + +1. 用 `deai-writing` 扫描并定向改写 Markdown。 +2. 用 `longdoc-docx` 生成 DOCX,并借助临时 PDF 做空白页、乱码和 300 DPI 视觉复核。 + +未安装时仍应交付结构完整、可追溯的 Markdown。 + +## 完成标准 + +需求无遗漏;架构、功能、实施、交付和验收闭合;事实与产品版本一致;图表和编号 +连续;假设、偏离、风险和非范围项明确;所有数字和承诺可定位到输入依据。 diff --git a/resources/skills/technical-proposal/templates/technical-proposal.md b/resources/skills/technical-proposal/templates/technical-proposal.md new file mode 100644 index 0000000..e35fcc5 --- /dev/null +++ b/resources/skills/technical-proposal/templates/technical-proposal.md @@ -0,0 +1,71 @@ +# {{项目名称}}技术方案 + +## 方案摘要 + +{{项目目标、方案范围、核心路径和验收结果,完成正文后编写。}} + +## 一、项目理解与需求分析 + +### 1. 业务目标 + +### 2. 需求分类 + +| 需求 ID | 原始要求 | 类型 | 关键约束 | 验收目标 | +|---|---|---|---|---| +| REQ-001 | {{原文}} | 功能 | {{约束}} | {{可验证结果}} | + +## 二、范围、假设与责任边界 + +### 1. 项目范围 + +### 2. 非范围项 + +### 3. 假设与待确认事项 + +### 4. 双方及第三方责任 + +## 三、总体技术方案 + +### 1. 总体架构 + +### 2. 组件职责与边界 + +### 3. 关键数据流 + +### 4. 部署与集成关系 + +## 四、详细设计 + +### 1. {{能力模块}} + +- 对应需求:{{REQ-001}} +- 产品能力:{{FEAT-001 / CLM-001}} +- 处理流程:{{输入、动作、输出}} +- 依赖与限制:{{条件}} +- 验收方法:{{步骤和证据}} + +## 五、非功能设计 + +### 1. 安全与审计 +### 2. 性能与容量 +### 3. 可用性、备份与恢复 +### 4. 兼容性与可维护性 + +## 六、实施、质量与交付 + +### 1. 实施阶段与里程碑 +### 2. 项目组织与沟通 +### 3. 质量保证与变更管理 +### 4. 交付物、培训与知识转移 + +## 七、验收、风险与偏离 + +### 1. 验收方案 +### 2. 风险及应对 +### 3. 技术偏离 + +## 八、需求追溯矩阵 + +| 需求 ID | 方案章节 | 产品事实 ID | 交付物 | 验收方法 | 状态 | +|---|---|---|---|---|---| +| REQ-001 | {{章节}} | {{FEAT-001}} | {{交付物}} | {{方法}} | 已覆盖 | diff --git a/resources/skills/tender-response-matrix/SKILL.md b/resources/skills/tender-response-matrix/SKILL.md new file mode 100644 index 0000000..102813a --- /dev/null +++ b/resources/skills/tender-response-matrix/SKILL.md @@ -0,0 +1,61 @@ +--- +name: tender-response-matrix +version: 1.0.0 +description: | + 将招标文件技术要求逐条拆解并生成符合性响应矩阵、缺口清单和方案章节映射。用于 + 投标前要求解析、响应检查和技术偏离管理;不制定采购参数,也不代写整篇方案。 +allowed-tools: + - Read + - Grep + - Glob +compatibility: Markdown/CSV;建议配合 product-evidence +--- + +# 招标响应矩阵 + +## 必要输入 + +- 招标文件原文及可定位的章节、页码或条款编号。 +- 产品事实、参数、证据、限制和规划能力。 +- 拟提交技术方案的章节结构。 +- 招标方规定的“响应、偏离、证明材料”格式。 + +## 解析规则 + +1. 保留原始条款,不用概括替代原文。 +2. 将一条中多个独立判定条件拆成子要求,同时保留父条款关系。 +3. 标记要求类型:功能、参数、接口、安全、服务、交付、验收或商务边界。 +4. 识别强制词、阈值、证明材料、评分点和截止条件。 +5. 每项只允许以下状态: + - `compliant`:完全满足且有证据。 + - `partial`:仅部分满足或有范围限制。 + - `not-compliant`:当前不能满足。 + - `clarification-required`:原文歧义或缺少必要输入。 +6. `planned` 能力不能用于判定当前 `compliant`,除非招标明确允许项目期内交付。 + +## 输出 + +使用 `templates/tender-response-matrix.md` 生成: + +- 逐条响应矩阵。 +- 技术偏离和澄清清单。 +- 证明材料清单。 +- 要求到方案章节、产品事实和验收方法的映射。 + +## 响应纪律 + +- “完全响应”必须有事实 ID、证据 ID和方案位置。 +- 原文要求高于已知产品能力时如实标记偏离,不得弱化原文。 +- 响应说明写具体实现、范围和条件,不重复“满足、响应”。 +- 尚未定稿的承诺标明审批责任人和截止时间,不能进入最终交付版。 +- 招标文件中的客户名称、项目编号和保密内容不得进入可复用技能模板。 + +## 两阶段使用 + +1. **方案编制前**:识别缺口,决定方案结构和需补充的证据。 +2. **方案完成后**:回填最终章节号、证明材料和验收方法,检查是否遗漏。 + +## 完成标准 + +原始要求覆盖率 100%;每个 `compliant` 有证据;偏离与澄清未被隐藏;条款编号、 +阈值和方案引用准确;矩阵与最终技术方案使用相同版本和术语。 diff --git a/resources/skills/tender-response-matrix/templates/tender-response-matrix.md b/resources/skills/tender-response-matrix/templates/tender-response-matrix.md new file mode 100644 index 0000000..b2a89e9 --- /dev/null +++ b/resources/skills/tender-response-matrix/templates/tender-response-matrix.md @@ -0,0 +1,28 @@ +# {{项目名称}}技术响应矩阵 + +## 一、逐条响应 + +| 要求 ID | 原条款位置 | 原文 | 类型 | 强制/评分 | 响应状态 | 具体响应 | 条件/偏离 | 事实与证据 | 方案章节 | 验收方法 | +|---|---|---|---|---|---|---|---|---|---|---| +| REQ-001 | {{章节/页码}} | {{完整原文}} | 功能 | 强制 | compliant | {{具体能力与范围}} | 无 | {{FEAT-001 / EVD-001}} | {{3.1}} | {{操作或材料核验}} | + +## 二、偏离与澄清 + +| 要求 ID | 状态 | 问题 | 影响 | 建议处理 | 责任人 | 截止时间 | +|---|---|---|---|---|---|---| +| REQ-002 | clarification-required | {{歧义或缺失信息}} | {{影响}} | {{澄清问题}} | {{责任人}} | {{日期}} | + +## 三、证明材料 + +| 材料 ID | 材料名称 | 对应要求 | 来源 | 公开级别 | 是否齐备 | +|---|---|---|---|---|---| +| MAT-001 | {{测试报告/证书/截图}} | REQ-001 | {{EVD-001}} | restricted | 是 | + +## 四、覆盖统计 + +- 原始要求数:{{数量}} +- 已拆分子要求数:{{数量}} +- 完全满足:{{数量}} +- 部分满足:{{数量}} +- 不满足:{{数量}} +- 待澄清:{{数量}} diff --git a/resources/skills/tender-technical-spec/SKILL.md b/resources/skills/tender-technical-spec/SKILL.md new file mode 100644 index 0000000..a710d8d --- /dev/null +++ b/resources/skills/tender-technical-spec/SKILL.md @@ -0,0 +1,68 @@ +--- +name: tender-technical-spec +version: 1.0.0 +description: | + 将已核验产品能力转写为可采购、可测试、可验收的招标技术规格和参数表。用于编制 + 招标文件技术要求、采购参数或技术规格书;不负责判断投标方是否符合,也不负责 + 撰写整篇投标技术方案。 +allowed-tools: + - Read + - Grep + - Glob +compatibility: Markdown;建议配合 product-evidence +--- + +# 招标技术参数 + +## 必要输入 + +- 产品事实与证据清单,尤其是功能、参数、限制、部署和兼容性。 +- 本次采购范围、部署规模、适用环境和验收阶段。 +- 强制项、推荐项和可选项的标记规则。 +- 是否允许品牌、专利或特定实现方式出现在参数中。 + +## 编制原则 + +1. 参数描述采购目标和可验证结果,避免锁定非必要的内部实现。 +2. 每项只表达一个可判定要求,不能把多个条件塞入一行。 +3. 数值必须包含单位、适用版本、测试条件和统计口径。 +4. 使用“应、须、不得”表示强制要求,“宜、可”表示推荐或可选要求。 +5. 为每项定义验收方法和所需证据,避免“支持、具备、先进”等无法判定的表述。 +6. 无证据或需采购方确认的内容进入待确认表,不得补造门槛值。 +7. 安全、兼容性、部署和服务要求分别成组,不能混入功能参数。 + +## 参数分类 + +- 总体与部署 +- 功能能力 +- 接口与集成 +- 性能与容量 +- 安全与审计 +- 兼容性与信创环境 +- 运维、备份与升级 +- 服务、培训与交付 +- 验收与材料 + +只保留与本次采购目标有关的分类。 + +## 输出 + +复制 `templates/tender-technical-spec.md`,生成: + +1. 技术规格正文。 +2. 可机读或可复制到表格的参数明细。 +3. 待确认参数与风险清单。 +4. 参数到事实证据的内部追溯表。 + +## 风险检查 + +- 是否把规划能力写成强制现有参数。 +- 是否为体现“先进”而编造性能阈值。 +- 是否把特定品牌或架构写成唯一实现路径,造成不必要排他性。 +- 是否存在无法复现的“高、快、强、稳定”等主观指标。 +- 是否遗漏测试数据、环境、并发模型、持续时间或误差范围。 + +## 完成标准 + +每项要求具备唯一编号、级别、参数内容、适用条件、验收方法和证据;全文无互相 +冲突的阈值;待确认项没有混入正式参数;公开与保密边界符合输入约束。 diff --git a/resources/skills/tender-technical-spec/templates/tender-technical-spec.md b/resources/skills/tender-technical-spec/templates/tender-technical-spec.md new file mode 100644 index 0000000..d8d4d6a --- /dev/null +++ b/resources/skills/tender-technical-spec/templates/tender-technical-spec.md @@ -0,0 +1,32 @@ +# {{项目名称}}招标技术规格 + +## 一、采购范围与适用条件 + +- 采购对象:{{产品/服务范围}} +- 部署环境:{{环境}} +- 适用版本:{{版本}} +- 验收阶段:{{阶段}} + +## 二、技术参数 + +| 编号 | 分类 | 级别 | 技术要求 | 条件与口径 | 验收方法 | 证据/材料 | +|---|---|---|---|---|---|---| +| TP-001 | 功能能力 | 强制 | 系统应{{可验证行为}} | {{版本、环境或前提}} | {{操作、测量或材料审查}} | {{EVD-001}} | + +## 三、交付与服务要求 + +| 编号 | 级别 | 要求 | 验收方式 | +|---|---|---|---| +| SV-001 | 强制 | {{交付物、培训或服务要求}} | {{材料或现场验收}} | + +## 四、待确认事项 + +| 编号 | 待确认内容 | 缺少依据 | 责任方 | 截止时间 | +|---|---|---|---|---| +| TBD-001 | {{参数或范围}} | {{需要补充的证据}} | {{责任方}} | {{日期}} | + +## 五、内部追溯表 + +| 参数编号 | 事实/主张 ID | 证据 ID | 复核结论 | +|---|---|---|---| +| TP-001 | {{CLM-001/PAR-001}} | {{EVD-001}} | {{通过/待确认}} | diff --git a/resources/skills/translation-polish/SKILL.md b/resources/skills/translation-polish/SKILL.md deleted file mode 100644 index 16e269c..0000000 --- a/resources/skills/translation-polish/SKILL.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -id: translation-polish -name: 翻译润色 -description: 在忠实保留原意、事实与格式的前提下完成翻译或润色,使表达自然、专业且符合目标语境。 -version: 1.0.0 -tags: - - 翻译 - - 润色 - - 语言 ---- - -# 翻译润色 - -## 工作原则 - -- 确认源语言、目标语言、读者、场景、语气和术语偏好。 -- 忠实保留事实、数字、日期、专有名词与不确定性。 -- 不擅自增删立场、承诺、限定条件或法律含义。 -- 对多义词、文化特定表达和术语冲突标注备选译法。 - -## 处理流程 - -1. 理解全文目的、上下文和语域。 -2. 建立关键术语及固定译法。 -3. 逐段转换含义,优先保证准确与连贯。 -4. 调整句式、语气和标点,使目标语言自然。 -5. 对照原文复核遗漏、误译、数字和格式。 - -## 输出方式 - -- 默认提供润色后的完整文本。 -- 存在关键歧义时,附“译法说明”与简短理由。 -- 用户要求对照时,按段落展示原文与译文。 -- 无法确认的术语或专名保留原文并标记“待确认”。 - -对于合同、医疗或其他高风险文本,应提醒用户进行专业复核。 diff --git a/resources/skills/weekly-report/SKILL.md b/resources/skills/weekly-report/SKILL.md deleted file mode 100644 index 9ed55b4..0000000 --- a/resources/skills/weekly-report/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -id: weekly-report -name: 周报整理 -description: 将零散工作记录整理为结果导向的周报,呈现进展、价值、风险与下周计划。 -version: 1.0.0 -tags: - - 周报 - - 汇报 - - 进展 ---- - -# 周报整理 - -## 工作原则 - -- 优先呈现已完成结果及其影响,而非简单罗列活动。 -- 仅使用用户提供的数据,不夸大进度、效果或完成度。 -- 明确区分已完成、进行中、受阻和计划事项。 -- 风险描述应客观,并给出已知的应对方案或支持需求。 - -## 整理流程 - -1. 按目标或项目归类本周记录。 -2. 将过程描述改写为“行动—结果—影响”。 -3. 提取里程碑、关键数据、风险和依赖。 -4. 按优先级排列下周计划。 -5. 检查时间范围、状态和数据口径是否一致。 - -## 输出模板 - -### 本周成果 - -- 目标、完成结果及业务或团队影响。 - -### 进行中事项 - -- 当前状态、下一步与预计节点(如已知)。 - -### 风险与支持需求 - -- 风险、影响、应对措施和所需支持。 - -### 下周计划 - -- 按优先级列出目标、交付物与关键节点。 - -不确定的信息标注“待确认”,避免使用模糊的完成度表述。 diff --git a/src/main/agent/continue-runtime.test.ts b/src/main/agent/continue-runtime.test.ts index 38393b1..60bfbe5 100644 --- a/src/main/agent/continue-runtime.test.ts +++ b/src/main/agent/continue-runtime.test.ts @@ -225,6 +225,45 @@ describe('ContinueAgentRuntime', () => { expect(prompt).toContain('test') }) + it('keeps a full bundled Skill payload on every platform', async () => { + const runtime = new ContinueAgentRuntime({ + binaryPath: '', + configPath: 'C:\\safe config\\continue.yaml', + defaultWorkspace: process.cwd(), + hostCacheRoot: 'C:\\safe\\continue-host', + skillInstructions: `# Skills\n${'技'.repeat(30_000)}`.slice(0, 30_000), + createHostAdapter: () => ({ + getPreparedHost: mocks.prepareHost, + run: mocks.runHost, + dispose: mocks.disposeHost + }) + }) + + await collectEvents(runtime) + + const prompt = String(mocks.runHost.mock.calls[0]?.[0]) + expect(prompt).toContain('SYSTEM CAPABILITY INSTRUCTIONS') + expect(prompt.length).toBeGreaterThan(24_000) + }) + + it('reports oversized Skill payloads instead of dropping them silently', async () => { + const runtime = new ContinueAgentRuntime({ + binaryPath: '', + configPath: 'C:\\safe config\\continue.yaml', + defaultWorkspace: process.cwd(), + hostCacheRoot: 'C:\\safe\\continue-host', + skillInstructions: '巨'.repeat(130_000), + createHostAdapter: () => ({ + getPreparedHost: mocks.prepareHost, + run: mocks.runHost, + dispose: mocks.disposeHost + }) + }) + + await expect(collectEvents(runtime)).rejects.toThrow('超过 Continue') + expect(mocks.runHost).not.toHaveBeenCalled() + }) + it('blocks anonymous platform fallback without an explicit model configuration', async () => { const runtime = new ContinueAgentRuntime({ binaryPath: '', diff --git a/src/main/agent/continue-runtime.ts b/src/main/agent/continue-runtime.ts index 41164ef..673fe34 100644 --- a/src/main/agent/continue-runtime.ts +++ b/src/main/agent/continue-runtime.ts @@ -43,8 +43,9 @@ export type ContinueRuntimeOptions = { > } -const MAX_CONTINUE_PROMPT_CHARACTERS = - process.platform === 'win32' ? 24_000 : 128_000 +// The prompt reaches the Continue host through a local HTTP POST body, so no +// platform command-line limit applies to it. +const MAX_CONTINUE_PROMPT_CHARACTERS = 128_000 function continueToolFailureMessage(tool: ContinueHostTool): string { const callId = tool.callId.slice(0, 128) @@ -260,12 +261,19 @@ export class ContinueAgentRuntime implements AgentRuntime { 'CURRENT CONVERSATION:' ].join('\n') : '' - const conversationContext = + if ( skillPrefix && - skillPrefix.length + prompt.length <= - MAX_CONTINUE_PROMPT_CHARACTERS - ? `${skillPrefix}\n${prompt}` - : prompt + skillPrefix.length + prompt.length > MAX_CONTINUE_PROMPT_CHARACTERS + ) { + throw new Error( + `已启用的 Skill 说明与当前请求合计 ${( + skillPrefix.length + prompt.length + ).toLocaleString()} 字符,超过 Continue ${MAX_CONTINUE_PROMPT_CHARACTERS.toLocaleString()} 字符上限。请在设置中减少分配给 Continue 的 Skill。` + ) + } + const conversationContext = skillPrefix + ? `${skillPrefix}\n${prompt}` + : prompt const detection = await this.getDetection() signal.throwIfAborted() if (!detection.available || !detection.path) { diff --git a/src/main/capabilities/builtin-skills.test.ts b/src/main/capabilities/builtin-skills.test.ts new file mode 100644 index 0000000..fab4fb3 --- /dev/null +++ b/src/main/capabilities/builtin-skills.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + CapabilityService, + type CapabilityCipher +} from './capability-service' +import { + BrowserProfileService, + MemoryBrowserProfileStore +} from './browser-profile-service' + +const builtinSkillsRoot = join( + process.cwd(), + 'resources', + 'skills' +) + +const cipher: CapabilityCipher = { + isAvailable: () => true, + encrypt: (value) => Buffer.from(`encrypted:${value}`), + decrypt: (value) => value.toString().replace(/^encrypted:/u, '') +} + +const temporaryDirectories: string[] = [] + +async function createService(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-builtin-')) + temporaryDirectories.push(directory) + return new CapabilityService( + join(directory, 'capabilities.json'), + builtinSkillsRoot, + join(directory, 'imported'), + cipher, + { + platform: 'win32', + architecture: 'x64', + electronTarget: true, + browserProfiles: new BrowserProfileService( + new MemoryBrowserProfileStore() + ) + } + ) +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +describe('bundled skills', () => { + it('parses every bundled SKILL.md', async () => { + const snapshot = await (await createService()).getSnapshot() + + expect(snapshot.skills.length).toBeGreaterThan(0) + expect(snapshot.skills.every((skill) => skill.source === 'builtin')).toBe( + true + ) + expect(snapshot.skills.map((skill) => skill.id)).toContain( + 'product-marketing' + ) + }) + + it('injects every enabled bundled skill with its resolved directory', async () => { + const service = await createService() + const snapshot = await service.getSnapshot() + + const instructions = await service.getSkillInstructions('continue') + + expect(instructions).not.toContain('因超出注入上限未加载') + for (const skill of snapshot.skills) { + expect(instructions).toContain(join(builtinSkillsRoot, skill.id)) + } + }) +}) diff --git a/src/main/capabilities/capability-service.test.ts b/src/main/capabilities/capability-service.test.ts index 4e3f70c..2a0f345 100644 --- a/src/main/capabilities/capability-service.test.ts +++ b/src/main/capabilities/capability-service.test.ts @@ -266,6 +266,77 @@ describe('CapabilityService', () => { ).rejects.toThrow('只能删除已导入') }) + it('imports a standard SKILL.md that identifies itself by name', async () => { + const { directory, service } = await createService() + const source = join(directory, 'standard-source', 'summarize-diff') + await mkdir(source, { recursive: true }) + await writeFile( + join(source, 'SKILL.md'), + [ + '---', + 'name: summarize-diff', + 'description: |', + ' 概括暂存的改动。', + ' 当用户需要待提交变更摘要时使用。', + 'allowed-tools:', + ' - Read', + ' - Grep', + 'compatibility: droid', + '---', + '', + '# Summarize Diff', + '', + '仅用于离线测试。' + ].join('\n'), + 'utf8' + ) + + const imported = await service.importSkill(source) + + expect(imported.skills).toContainEqual( + expect.objectContaining({ + id: 'summarize-diff', + source: 'imported', + description: '概括暂存的改动。 当用户需要待提交变更摘要时使用。' + }) + ) + }) + + it('imports every Skill found under a suite directory', async () => { + const { directory, service } = await createService() + const suite = join(directory, 'suite', 'skills') + await writeSkill(suite, 'alpha-skill', 'Alpha') + await writeSkill(suite, 'beta-skill', 'Beta') + + const imported = await service.importSkill(join(directory, 'suite')) + + expect(imported.skills.map((skill) => skill.id)).toEqual( + expect.arrayContaining(['alpha-skill', 'beta-skill']) + ) + }) + + it('reports a readable error when the selected directory has no SKILL.md', async () => { + const { directory, service } = await createService() + const empty = join(directory, 'empty-directory') + await mkdir(empty, { recursive: true }) + + await expect(service.importSkill(empty)).rejects.toThrow( + '没有找到 SKILL.md' + ) + }) + + it('exposes the skill directory and names skills dropped by the budget', async () => { + const { builtinRoot, service } = await createService() + await writeSkill(builtinRoot, 'oversized-skill', '超长技能') + + const instructions = await service.getSkillInstructions('model') + expect(instructions).toContain(join(builtinRoot, 'document-writing')) + expect(instructions).toContain(join(builtinRoot, 'oversized-skill')) + + const truncated = await service.getSkillInstructions('model', 200) + expect(truncated).toContain('因超出注入上限未加载') + }) + it('imports a managed Skill from a ZIP package', async () => { const { directory, importedRoot, service } = await createService() const packageRoot = join(directory, 'zip-source') diff --git a/src/main/capabilities/capability-service.ts b/src/main/capabilities/capability-service.ts index 4b1d226..2ec7c68 100644 --- a/src/main/capabilities/capability-service.ts +++ b/src/main/capabilities/capability-service.ts @@ -56,16 +56,31 @@ const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024 const MAX_SKILL_PACKAGE_BYTES = 10 * 1024 * 1024 const MAX_SKILL_PACKAGE_FILES = 128 const MAX_SKILL_DEPTH = 6 +const MAX_SKILL_DISCOVERY_DEPTH = 4 +const MAX_SKILL_DISCOVERY_RESULTS = 64 +const MAX_SKILL_INSTRUCTION_CHARACTERS = 262_144 +const SKILL_DISCOVERY_IGNORED_DIRECTORIES = new Set([ + 'node_modules', + '__pycache__', + '__MACOSX' +]) -const skillMetadataSchema = z - .object({ - id: skillIdSchema, - name: z.string().trim().min(1).max(80), - description: z.string().trim().min(1).max(500), - version: z.string().trim().min(1).max(32).optional(), - tags: z.array(z.string().trim().min(1).max(32)).max(12).default([]) - }) - .strict() +// Block scalars in SKILL.md frontmatter carry newlines that would break the +// single-line summary surfaces the renderer and runtimes rely on. +function collapsedText(maximum: number): z.ZodType { + return z + .string() + .transform((value) => value.replace(/\s+/gu, ' ').trim()) + .pipe(z.string().min(1).max(maximum)) +} + +const skillMetadataSchema = z.object({ + id: skillIdSchema.optional(), + name: collapsedText(80), + description: collapsedText(500), + version: collapsedText(32).optional(), + tags: z.array(collapsedText(32)).max(12).default([]) +}) const skillStateSchema = z .object({ @@ -221,13 +236,22 @@ async function readSkill( throw new Error(`${basename(directoryPath)} 的 SKILL.md 格式无效`) } const metadata = skillMetadataSchema.parse(parseYaml(match[1])) - if (expectedId !== null && metadata.id !== expectedId) { - throw new Error(`Skill ID 必须与目录名一致:${metadata.id}`) + // Standard SKILL.md files identify the skill by `name`; GoodBuddy packages + // add an explicit `id` alongside a human-readable `name`. + const identifier = skillIdSchema.safeParse(metadata.id ?? metadata.name) + if (!identifier.success) { + throw new Error( + `${basename(directoryPath)} 的 SKILL.md 缺少可用的 Skill ID,请提供小写连字符格式的 id 或 name` + ) + } + if (expectedId !== null && identifier.data !== expectedId) { + throw new Error(`Skill ID 必须与目录名一致:${identifier.data}`) } return skillSummarySchema .omit({ enabled: true, assignments: true }) .parse({ ...metadata, + id: identifier.data, source, digest: createHash('sha256').update(content).digest('hex') }) @@ -261,6 +285,55 @@ async function listSkills( ) } +async function pathExists(candidate: string): Promise { + return stat(candidate) + .then(() => true) + .catch(() => false) +} + +// Mirrors the conventional layout where the first directory containing +// SKILL.md is the skill root, so users can pick a suite directory that holds +// many skills instead of one package at a time. +async function discoverSkillDirectories(root: string): Promise { + if (await pathExists(join(root, 'SKILL.md'))) { + return [root] + } + const found: string[] = [] + const walk = async (current: string, depth: number): Promise => { + if (depth > MAX_SKILL_DISCOVERY_DEPTH || found.length > MAX_SKILL_DISCOVERY_RESULTS) { + return + } + let entries + try { + entries = await readdir(current, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name.startsWith('.') || + SKILL_DISCOVERY_IGNORED_DIRECTORIES.has(entry.name) + ) { + continue + } + const child = join(current, entry.name) + if (await pathExists(join(child, 'SKILL.md'))) { + found.push(child) + continue + } + await walk(child, depth + 1) + } + } + await walk(root, 0) + if (found.length > MAX_SKILL_DISCOVERY_RESULTS) { + throw new Error( + `所选目录包含的 Skill 超过 ${MAX_SKILL_DISCOVERY_RESULTS} 个,请选择更精确的目录` + ) + } + return found.sort((left, right) => left.localeCompare(right)) +} + async function copySkillPackage( sourceRoot: string, targetRoot: string @@ -943,6 +1016,46 @@ export class CapabilityService { }) } + private async importSkillDirectory( + sourceDirectory: string, + expectedId: string | null | undefined + ): Promise { + const temporaryPath = join( + this.importedSkillsRoot, + `.import-${randomUUID()}` + ) + try { + const skill = await readSkill( + sourceDirectory, + 'imported', + expectedId + ) + const builtins = await listSkills(this.builtinSkillsRoot, 'builtin') + if (builtins.some((item) => item.id === skill.id)) { + throw new Error('导入的 Skill ID 与内置 Skill 冲突') + } + const targetPath = join(this.importedSkillsRoot, skill.id) + if (await pathExists(targetPath)) { + throw new Error('同名 Skill 已导入,请先删除后重试') + } + await copySkillPackage(sourceDirectory, temporaryPath) + await readSkill(temporaryPath, 'imported', skill.id) + await rename(temporaryPath, targetPath) + const state = await this.load() + await this.persist({ + ...state, + skills: { + ...state.skills, + [skill.id]: defaultSkillState() + } + }) + return skill.id + } catch (error) { + await rm(temporaryPath, { recursive: true, force: true }) + throw error + } + } + importSkill(sourcePath: string): Promise { return this.queue(async () => { const canonicalSource = await realpath(sourcePath) @@ -955,52 +1068,61 @@ export class CapabilityService { throw new Error('所选 Skill 路径必须是目录或 .zip 文件') } await mkdir(this.importedSkillsRoot, { recursive: true }) - const temporaryPath = join( - this.importedSkillsRoot, - `.import-${randomUUID()}` - ) - try { - const archiveDirectoryName = isZip - ? await extractSkillZip(canonicalSource, temporaryPath) - : undefined - const skill = await readSkill( - isDirectory ? canonicalSource : temporaryPath, - 'imported', - isDirectory ? undefined : (archiveDirectoryName ?? null) + + if (isZip) { + const extractPath = join( + this.importedSkillsRoot, + `.extract-${randomUUID()}` ) - const builtins = await listSkills( - this.builtinSkillsRoot, - 'builtin' - ) - if (builtins.some((item) => item.id === skill.id)) { - throw new Error('导入的 Skill ID 与内置 Skill 冲突') + try { + const archiveDirectoryName = await extractSkillZip( + canonicalSource, + extractPath + ) + await this.importSkillDirectory( + extractPath, + archiveDirectoryName ?? null + ) + } finally { + await rm(extractPath, { recursive: true, force: true }) } - const targetPath = join(this.importedSkillsRoot, skill.id) - if ( - await stat(targetPath) - .then(() => true) - .catch(() => false) - ) { - throw new Error('同名 Skill 已导入,请先删除后重试') - } - if (isDirectory) { - await copySkillPackage(canonicalSource, temporaryPath) - } - await readSkill(temporaryPath, 'imported', skill.id) - await rename(temporaryPath, targetPath) - const state = await this.load() - await this.persist({ - ...state, - skills: { - ...state.skills, - [skill.id]: defaultSkillState() - } - }) return this.getSnapshot() - } catch (error) { - await rm(temporaryPath, { recursive: true, force: true }) - throw error } + + const directories = await discoverSkillDirectories(canonicalSource) + if (directories.length === 0) { + throw new Error( + '所选目录及其子目录中没有找到 SKILL.md,请选择 Skill 目录或包含多个 Skill 的目录' + ) + } + const failures: string[] = [] + let importedCount = 0 + for (const directory of directories) { + try { + // A suite directory may nest skills below its own name, so the + // directory name is only authoritative for a single-skill import. + await this.importSkillDirectory( + directory, + directories.length === 1 ? undefined : null + ) + importedCount += 1 + } catch (error) { + failures.push( + `${basename(directory)}:${ + error instanceof Error ? error.message : '导入失败' + }` + ) + } + } + if (importedCount === 0) { + throw new Error(`Skill 导入失败。${failures.join(';')}`) + } + if (failures.length > 0) { + throw new Error( + `已导入 ${importedCount} 个 Skill,${failures.length} 个失败。${failures.join(';')}` + ) + } + return this.getSnapshot() }) } @@ -1202,10 +1324,15 @@ export class CapabilityService { async getSkillInstructions( target: RuntimeTarget, - maximumCharacters: number + maximumCharacters: number = MAX_SKILL_INSTRUCTION_CHARACTERS ): Promise { + const budget = Math.min( + maximumCharacters, + MAX_SKILL_INSTRUCTION_CHARACTERS + ) const snapshot = await this.getSnapshot() const sections: string[] = [] + const skipped: string[] = [] let length = 0 for (const skill of snapshot.skills) { if (!skill.enabled || !skill.assignments.includes(target)) { @@ -1215,24 +1342,38 @@ export class CapabilityService { skill.source === 'builtin' ? this.builtinSkillsRoot : this.importedSkillsRoot - const content = await readFile(join(root, skill.id, 'SKILL.md'), 'utf8') + const directory = join(root, skill.id) + const content = await readFile(join(directory, 'SKILL.md'), 'utf8') const body = /^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]+)$/u.exec(content)?.[1]?.trim() ?? '' - const section = `## ${skill.name}\n${body}` - if (length + section.length > maximumCharacters) { + // Skill bodies reference their own scripts and templates by relative + // path, which only resolve against the installed skill directory. + const section = [ + `## ${skill.name}`, + `Skill 目录:${directory}`, + body + ].join('\n') + if (length + section.length > budget) { + skipped.push(skill.name) continue } sections.push(section) length += section.length } - return sections.length > 0 - ? [ - '# GoodBuddy 已启用 Skills', - '以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。', - ...sections - ].join('\n\n') - : '' + if (sections.length === 0) { + return '' + } + return [ + '# GoodBuddy 已启用 Skills', + '以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。', + ...(skipped.length > 0 + ? [ + `注意:以下 Skill 因超出注入上限未加载,本次对话不可用:${skipped.join('、')}。` + ] + : []), + ...sections + ].join('\n\n') } async getResolvedMcpServers( diff --git a/src/main/index.ts b/src/main/index.ts index c091161..72bd40c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -346,10 +346,7 @@ if (hasSingleInstanceLock) { ): Promise => { const [skillInstructions, mcpServers, browserCapability] = await Promise.all([ - capabilityService.getSkillInstructions( - target, - target === 'continue' ? 12_000 : 48_000 - ), + capabilityService.getSkillInstructions(target), target === 'model' ? capabilityService.getResolvedMcpServers('model') : Promise.resolve([]),