feat: improve skill import and runtime delivery
This commit is contained in:
@@ -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` 生成:
|
||||
|
||||
- 比较范围和方法。
|
||||
- 竞品能力矩阵。
|
||||
- 来源台账。
|
||||
- 本产品适合赢得和不适合争夺的场景。
|
||||
- 经证据支持的差异化表述。
|
||||
- 销售问答与禁止话术。
|
||||
|
||||
## 定位原则
|
||||
|
||||
- 定位说明“对特定受众,在特定场景下为什么适合”,不是宣布全面领先。
|
||||
- 差异点必须对应用户决策标准和可验证产品事实。
|
||||
- 明确本产品限制,避免销售把定位扩张成产品承诺。
|
||||
- 竞品材料过期、版本不明或来源撤回时,相关结论立即失效。
|
||||
|
||||
## 完成标准
|
||||
|
||||
每个比较结论有来源和日期;未知与不支持严格分开;比较维度一致;没有贬损性、
|
||||
法律风险或未经批准的价格信息;定位与当前产品版本、功能状态和适用边界一致。
|
||||
@@ -0,0 +1,39 @@
|
||||
# {{产品名称}}竞品与定位分析
|
||||
|
||||
**比较日期**:{{YYYY-MM-DD}}
|
||||
**比较范围**:{{市场、版本、部署和授权范围}}
|
||||
**使用限制**:内部 / 受控销售 / 可公开
|
||||
|
||||
## 一、评价维度与方法
|
||||
|
||||
| 维度 | 判定标准 | 数据来源要求 |
|
||||
|---|---|---|
|
||||
| {{维度}} | {{统一标准}} | {{官方文档/测试报告}} |
|
||||
|
||||
## 二、竞品矩阵
|
||||
|
||||
| 维度 | 本产品 | 竞品 A | 竞品 B |
|
||||
|---|---|---|---|
|
||||
| {{维度}} | {{结论 [verified]}} | {{结论 [verified/inferred/unknown]}} | {{结论}} |
|
||||
|
||||
## 三、来源台账
|
||||
|
||||
| 来源 ID | 产品 | 标题 | URL/文档 | 版本 | 日期 | 访问日期 | 权威性 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| SRC-001 | {{产品}} | {{标题}} | {{来源}} | {{版本}} | {{日期}} | {{日期}} | 官方 |
|
||||
|
||||
## 四、定位
|
||||
|
||||
### 适合优先争取的场景
|
||||
|
||||
- {{目标受众 + 场景 + 可验证差异}}
|
||||
|
||||
### 不适合或需谨慎的场景
|
||||
|
||||
- {{本产品限制或竞品明确优势}}
|
||||
|
||||
## 五、销售问答与禁止话术
|
||||
|
||||
| 客户问题 | 有依据的回答 | 来源 | 禁止表述 |
|
||||
|---|---|---|---|
|
||||
| {{问题}} | {{回答}} | {{SRC/CLM ID}} | {{无依据绝对化说法}} |
|
||||
@@ -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、引语和图片;案例描述与实施记录一致;指标可
|
||||
复算;产品贡献不过度归因;限制清楚;公开版不含客户隐私、合同信息或内部路径。
|
||||
@@ -0,0 +1,47 @@
|
||||
# {{客户授权名称或匿名描述}}:{{案例主题}}
|
||||
|
||||
> 发布状态:内部草稿 / 客户审核中 / 已批准公开
|
||||
> 授权记录:{{授权文件与范围}}
|
||||
> 产品版本:{{版本}}
|
||||
|
||||
## 客户背景
|
||||
|
||||
{{只写获准披露的行业、规模和业务范围。}}
|
||||
|
||||
## 实施前任务与基线
|
||||
|
||||
| 指标 | 基线 | 周期与样本 | 数据来源 |
|
||||
|---|---:|---|---|
|
||||
| {{指标}} | {{数值}} | {{周期、样本}} | {{来源}} |
|
||||
|
||||
## 方案与实施范围
|
||||
|
||||
- 产品参与:{{工作流中的具体作用}}
|
||||
- 客户责任:{{数据、流程、人员或审核}}
|
||||
- 第三方条件:{{依赖}}
|
||||
- 非范围项:{{不属于本案例的内容}}
|
||||
|
||||
## 实施过程
|
||||
|
||||
{{阶段、关键动作和变更,不写无法核验的戏剧化叙事。}}
|
||||
|
||||
## 结果与测量方法
|
||||
|
||||
| 指标 | 基线 | 结果 | 变化 | 测量条件 | 证据 |
|
||||
|---|---:|---:|---:|---|---|
|
||||
| {{指标}} | {{值}} | {{值}} | {{值}} | {{口径}} | {{EVD-001}} |
|
||||
|
||||
## 客户引语
|
||||
|
||||
> “{{仅使用获批原文}}”
|
||||
|
||||
## 适用边界与经验
|
||||
|
||||
{{限制、样本边界、人工复核要求和可复用经验。}}
|
||||
|
||||
## 发布审批
|
||||
|
||||
| 内容 | 授权范围 | 审批人 | 日期 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| 客户名称/Logo | {{范围}} | {{审批人}} | {{日期}} | {{状态}} |
|
||||
| 指标与引语 | {{范围}} | {{审批人}} | {{日期}} | {{状态}} |
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
id: data-summary
|
||||
name: 数据摘要
|
||||
description: 将用户提供的数据或统计结果压缩为准确、易读的摘要,突出趋势、差异与限制。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 数据
|
||||
- 摘要
|
||||
- 汇报
|
||||
---
|
||||
|
||||
# 数据摘要
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 保留原始单位、时间范围、样本范围和统计口径。
|
||||
- 不补造数值,不隐去影响解释的重要异常或限制。
|
||||
- 使用绝对值与相对变化时,清楚标注基准。
|
||||
- 避免把描述性结果升级为因果结论或普遍规律。
|
||||
|
||||
## 摘要流程
|
||||
|
||||
1. 明确摘要面向的读者和需要回答的问题。
|
||||
2. 识别总量、趋势、结构、差异和异常。
|
||||
3. 核对数字之间的关系及四舍五入口径。
|
||||
4. 按重要性筛选少量关键发现。
|
||||
5. 补充数据质量、样本和解释边界。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **一句话结论:** 最重要且有数据支持的信息
|
||||
- **关键数字:** 数值、单位、周期和对比基准
|
||||
- **主要趋势:** 方向、幅度和持续时间
|
||||
- **值得关注:** 异常、分组差异或转折点
|
||||
- **限制说明:** 缺失、偏差或不可比较之处
|
||||
|
||||
若用户未提供足够数据,先列出缺口,不以推测代替结果。
|
||||
@@ -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`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/deai_scan.py" 方案.md # 单文件
|
||||
<python> "<skill-dir>/deai_scan.py" docs/ --ext .md # 递归目录
|
||||
<python> "<skill-dir>/deai_scan.py" public/ --rules my_site # 叠加项目词典
|
||||
<python> "<skill-dir>/deai_scan.py" 方案.md --json # 结构化输出,喂给模型改写
|
||||
<python> "<skill-dir>/deai_scan.py" 方案.md --fail-on-block # CI 门禁,阻断项非零则退出 1
|
||||
```
|
||||
|
||||
`<skill-dir>` 指本 `SKILL.md` 所在目录。不要假定技能安装在固定路径,项目技能、
|
||||
个人技能和插件技能的安装位置不同。
|
||||
|
||||
输出分两级:
|
||||
|
||||
- **阻断项**:命中即应改写,目标压到接近 0。
|
||||
- **复核项**:只是候选,结合页面类型、事实边界和专业语境逐条判断,**不追求
|
||||
机械清零**。研究文章里的「闭环」如果确有定义就该留着。
|
||||
|
||||
标准迭代:
|
||||
|
||||
1. 先确认文档类型、目标读者、称谓和不能改变的事实边界。
|
||||
2. 扫描源文件,将 JSON 命中清单与原文一起交给 Agent 定向改写。
|
||||
3. 逐条核对改写没有编造数字、删除限制条件或改变责任主体。
|
||||
4. 复扫,直到阻断项收敛;逐条处理复核项,不机械清零。
|
||||
5. 通读全文,检查关键词扫描无法发现的前后矛盾和主体错位。
|
||||
|
||||
扫描器只负责定位,不提供自动替换。语义改写必须由 Agent 结合上下文完成,
|
||||
避免把专业术语、法定提示和事实边界误删。
|
||||
|
||||
脚本默认跳过 Markdown 代码块和 HTML 的 `<script>/<style>/<pre>/<code>`,避免
|
||||
把代码里的 `not ... but` 误判成对照模板。
|
||||
|
||||
## 病征清单
|
||||
|
||||
### 1. 套路化连接词与转折
|
||||
`不仅……而且`、`更是`、`无疑`、`毫无疑问`、`值得注意的是`、`需要指出的是`、
|
||||
`总而言之`、`综上所述`、`总的来说`、`换言之`、`一言以蔽之`、`众所周知`。
|
||||
|
||||
改法:直接删掉这些提示词,把后面的内容当正文说。中文母语写作很少这样起承
|
||||
转合,观点直接给结论。
|
||||
|
||||
### 2. 空心形容词与抽象术语
|
||||
`强大的`、`卓越的`、`高效的`、`全方位`、`一站式`、`赋能`、`助力`、`打造`、
|
||||
`深耕`、`护航`、`保驾护航`、`量身定制`、`极致`、`无缝`。
|
||||
|
||||
改法:换成可验证的具体事实。「强大的性能」→「单卡 141GB 显存,可常驻 3 个
|
||||
模型」;「高效赋能研发」→「代码审查从人工 30 分钟降到自动 2 分钟」。**能用
|
||||
数字或具体动作说清的,绝不用形容词。**
|
||||
|
||||
`闭环`、`底座`、`形成衔接`、`共同约束` 这类抽象搭配要复核:原句若没说明具体
|
||||
组件、关系或动作,改成 `平台`、`连接`、`共同限定`、`由……校验`;确有定义的
|
||||
架构、控制理论或工程语境可以保留。
|
||||
|
||||
**具体数字必须来自可复查证据,不能为替换空心形容词而编造指标。**
|
||||
|
||||
### 3. 排比与三段式强迫症
|
||||
命中信号:连续三项结构完全对称的短语;每段都凑成三点;每个要点长度刻意一致。
|
||||
|
||||
改法:打破对称。该两点就两点,该五点就五点;长短句混用;把排比拆成陈述句。
|
||||
|
||||
### 4. 开头的宏大叙事
|
||||
`随着……的快速发展`、`在……的今天`、`在数字化转型的大背景下`、`当前,……`、
|
||||
`近年来,……`。
|
||||
|
||||
改法:删掉铺垫,第一句直接进入主题。正式方案的读者不需要背景朗诵。
|
||||
|
||||
### 5. 结尾的空洞升华
|
||||
`让我们携手……`、`共同开创……的美好未来`、`为……贡献力量`、`必将……`、
|
||||
`奠定坚实基础`。
|
||||
|
||||
改法:正式文档结尾给可执行结论或下一步动作,不喊口号。
|
||||
|
||||
### 6. 过度自我指涉与礼貌层
|
||||
过量的 `我们`、`我方`、`本方案`、`本系统旨在`、`致力于`;`希望能对您有所帮助`、
|
||||
`如有需要,欢迎随时联系` 这类客服尾巴。
|
||||
|
||||
改法:正式技术文档以事实和系统为主语;删掉客服式收尾。
|
||||
|
||||
### 7. 机械的分点与加粗
|
||||
命中信号:几乎每句话都是一个 bullet;每个 bullet 都加粗前半句做伪标题;
|
||||
`首先/其次/再次/最后` 生硬编号。
|
||||
|
||||
改法:叙述性内容用段落写,列表只留真正并列、需要逐条对照的信息。
|
||||
|
||||
### 8. 中英标点与格式痕迹
|
||||
滥用破折号 `——`;中文里夹英文半角逗号/括号;`:` 后强行分号排比;Emoji;
|
||||
`✅❌🚀` 等符号。
|
||||
|
||||
改法:破折号能换成逗号、括号或分句就换掉;中文全角标点统一;不用 Emoji。
|
||||
|
||||
### 9. 冗余与同义反复
|
||||
`进行了……的操作`、`做出了……的决定`、`起到了……的作用`、`具有……的特点`、
|
||||
`实现了……的功能`。
|
||||
|
||||
改法:把「进行/做出/起到/具有/实现 + 名词」的绕弯结构还原成一个动词。
|
||||
「进行了优化的操作」→「优化了」。
|
||||
|
||||
### 10. 过度对冲与免责
|
||||
`可能`、`或许`、`在某种程度上`、`总体而言`、`一般来说` 的密集堆叠。
|
||||
|
||||
改法:有把握就直说;确需限定的地方保留一处即可。公司官网中的必要边界集中
|
||||
说明一次,优先用正向范围表述,例如「支持在约定数据源与人工复核流程下运行」,
|
||||
避免在标题、正文和 CTA 中反复出现 `不代表`、`不包含`、`尚未`、`不能`。
|
||||
|
||||
**研究方法限制、法定提示、安全边界和人工复核要求不适用上述压缩规则,必须
|
||||
按事实保留。**
|
||||
|
||||
### 11. 对照句式、问答式标题与人为凑数
|
||||
句式:`不是 A,而是 B`、`并非 A,而是 B`、`A,而不是 B`、`不先谈 A,先看 B`。
|
||||
英文的 `not A but B`、`rather than`、`instead of` 同属一类。
|
||||
|
||||
标题:`结果回答了三个具体问题`、`以下四点值得关注`、`三个发现`、
|
||||
`我们需要回答什么`。这类标题只描述文章结构,没有说明本节内容。
|
||||
|
||||
改法:删除对照框架,直接写 B;标题直接写研究对象或结果。
|
||||
|
||||
- 「拆分依据不是模块名称,而是控制复杂度与数据流特征」
|
||||
→ 「PS/PL 分工依据控制复杂度与数据流特征」
|
||||
- 「实测结果回答了三个具体问题」→「正确性、批量性能与时序结果」
|
||||
- 「交付标准围绕任务结果,而不是模型清单」→「以任务结果界定交付标准」
|
||||
|
||||
数量只能来自内容本身。确有三组测量结果时可以列三项,但标题不必强调「有三个
|
||||
问题」。
|
||||
|
||||
### 12. 公司官网写成实施教程
|
||||
命中信号:首屏用 `先把……接入……`、`从一个场景开始`、`第一步先……` 等操作
|
||||
指令;公司介绍围绕实施顺序展开,没有说明服务领域和技术能力。
|
||||
|
||||
改法:首页首屏先回答「公司面向哪些领域、提供什么服务」。实施步骤放到交付
|
||||
方式或产品详情里,不承担公司定位。标题用公司或能力主语,例如「面向专业领域,
|
||||
构建行业智能系统」。
|
||||
|
||||
CTA 应指向项目咨询、合作沟通或联系团队,避免 `按清单准备材料`、`从第一步
|
||||
开始`、`说明当前流程` 这类需求填报或实施指导语言;清单、模板和实施指南只放在
|
||||
明确标注的资料或交付页面。
|
||||
|
||||
首屏说明业务对象、能力范围与交付方式,不展开接口字段、配置步骤、临时文件、
|
||||
异常回退、队列状态和调试过程。必要技术细节下沉到技术说明,**不因去 AI 味而
|
||||
删除**。
|
||||
|
||||
### 13. 元话语标题
|
||||
`本节回答……`、`结果说明了什么`、`需要关注的几个问题`、`我们如何理解……`。
|
||||
标题在评论文章本身,没有给出信息。
|
||||
|
||||
改法:直接写主题、对象、指标或结论范围。研究文章优先用 `测试环境`、
|
||||
`批量性能`、`时序结果`、`适用边界` 等名词性标题。
|
||||
|
||||
慎用以 `把`、`让`、`先`、`再` 开头的操作口令;英文标题避免 `Bring...`、
|
||||
`Start...`、`Let...`、`First...` 祈使句,优先 `Project Consultation`、
|
||||
`Deployment Scope`、`Human Review` 等名词性标题。
|
||||
|
||||
研究和技术报告可以直接陈述测量范围与方法限制,例如「当前测量仅覆盖 INT8
|
||||
点积」,不要套成「这不是完整检索,而只是……」。
|
||||
|
||||
公司官网不公开 `当前基线`、`当前证据`、`已知缺口`、`成熟度等级`、`页面所述`
|
||||
等内部审查语言,改写为客户可理解的适用范围、接入条件和分阶段交付边界;规划
|
||||
能力仍须用将来时或设计阶段表述。
|
||||
|
||||
## 改写纪律
|
||||
|
||||
去 AI 味不是把文本改得干瘪,而是去套路、留信息。四条底线:
|
||||
|
||||
1. **只删套路,不删事实。** 形容词换成数字/动作是「换」不是「删信息」;
|
||||
连接词、铺垫、升华才是直接删。
|
||||
2. **保留专业术语与必要限定。** 技术文档里的约束/前提/风险不是对冲水词,
|
||||
该留;要删的是无意义的「可能、或许」堆叠。
|
||||
3. **不删除事实边界。** 保留研究指标的测试条件与统计口径、第三方来源和归属、
|
||||
人工复核要求、数据与接口条件及部署范围。去 AI 味不能改变成熟度,也不能把
|
||||
规划能力写成已经实现。
|
||||
4. **改完复读一遍出声。** AI 味的本质是结构过于工整、信息密度偏低,出声读
|
||||
最容易发现。
|
||||
|
||||
## 一分钟自查清单(不跑脚本时)
|
||||
|
||||
- 开头有没有「随着/在……的今天」?删。
|
||||
- 有没有「不仅……而且/综上所述/值得注意的是」?删。
|
||||
- 有没有「不是 A,而是 B」或「回答三个问题」式标题?直接写 B 或具体结果。
|
||||
- 公司首页是否写成了实施步骤?改成服务领域、产品能力和公司定位。
|
||||
- CTA 是否像需求填报表?改成项目咨询或合作沟通。
|
||||
- 官网是否出现「当前基线」「已知缺口」等内部审校语言?改成适用范围和交付条件。
|
||||
- 标题是否以 `把/让/先/再` 或 `Bring/Start/Let/First` 发出操作口令?改名词性。
|
||||
- 产品首屏是否塞入接口字段、回退链、调试过程?下沉到技术说明。
|
||||
- 限制是否在同一页面重复出现?合并为一处正向范围说明,同时保留必要的研究、
|
||||
安全和人工复核边界。
|
||||
- 形容词能不能换成数字或具体动作?能就换。
|
||||
- 是不是每段都凑三点、每句都加粗?打破它。
|
||||
- 有没有破折号、Emoji、客服式结尾?清掉。
|
||||
- 出声读一遍:像人说话,还是像念 PPT?
|
||||
|
||||
## 文件构成
|
||||
|
||||
```
|
||||
deai-writing/
|
||||
SKILL.md # 本文件
|
||||
deai_scan.py # 扫描器
|
||||
ai_smell_dict.py # 通用词典(跨项目)
|
||||
project_rules/
|
||||
example.py # 可复制的匿名项目词典模板
|
||||
tests/
|
||||
test_deai_scan.py # 扫描、屏蔽和项目词典回归测试
|
||||
```
|
||||
|
||||
## 持续进化
|
||||
|
||||
每次评审被挑出的新 AI 味用词,回填进词典:
|
||||
|
||||
1. 记录原句、评审意见和最终改法。
|
||||
2. 判断问题属于词语、句式、标题结构还是页面定位。
|
||||
3. 跨项目通用的进 `ai_smell_dict.py`;只与某站点/项目相关的进
|
||||
`project_rules/<项目>.py`,并在该文件的 `REVIEW_LOG` 里追加台账。
|
||||
4. 在整个项目扫描同类表达,不只修改被点名的那一句。
|
||||
5. 中英文同步处理,避免中文已改而英文仍留 `not...but`、`rather than` 或
|
||||
元话语标题。
|
||||
|
||||
新增项目词典:在 `project_rules/` 下新建 `<名字>.py`,导出 `AI_SMELL`、
|
||||
`REVIEW_ONLY`(都可选)和 `REVIEW_LOG`,用 `--rules <名字>` 加载。
|
||||
项目词典只允许上述变量的 Python 字面量赋值,扫描器不会执行其中的函数调用或
|
||||
导入语句。
|
||||
|
||||
共享或导出技能时,只带通用词典和匿名模板。项目专属规则可能包含客户名称、
|
||||
内部措辞和评审记录,不应进入分发包。
|
||||
|
||||
## 验证技能
|
||||
|
||||
```bash
|
||||
<python> -m unittest discover -s "<skill-dir>/tests" -p "test_*.py"
|
||||
<python> "<skill-dir>/deai_scan.py" "<skill-dir>/SKILL.md" --json
|
||||
```
|
||||
|
||||
测试必须覆盖阻断项、复核项、Markdown/HTML 代码区屏蔽、目录扫描和自定义词典。
|
||||
增加或调整规则后先补回归样例,再发布新版本。
|
||||
|
||||
## 已知边界
|
||||
|
||||
- 结构性问题(排比、三段式、每段凑三点)正则只能给候选,最终要人读。
|
||||
- 「不仅……而且」等词单独出现误报率高,词典里已收敛成句式匹配;仍会有误报,
|
||||
阻断项要逐条看过再改,不能盲目全局替换。
|
||||
- 扫描器不判断事实正确性。改写时新引入的数字必须有证据支撑。
|
||||
@@ -0,0 +1,109 @@
|
||||
"""通用「AI 味」病症词典。
|
||||
|
||||
两级:
|
||||
AI_SMELL 阻断项 —— 命中即应改写,目标压到接近 0。
|
||||
REVIEW_ONLY 复核项 —— 只作人工判断候选,不能作为自动删除或发布失败条件。
|
||||
|
||||
词条可以是普通字符串(按子串匹配)或正则(`re` 语法)。扫描器统一用
|
||||
`re.search` 处理,普通字符串里的正则元字符需要自行转义。
|
||||
|
||||
扩充规则:每次评审被挑出的新 AI 味用词,回填到这里;只与单个项目/站点
|
||||
相关的规则不要放这里,放 project_rules/ 下的项目词典。
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 阻断项
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AI_SMELL = {
|
||||
"套路连接词": [
|
||||
# "不仅/而且" 单列误报率过高,收敛成句式匹配(原清单的精确化)
|
||||
r"不仅.{0,40}(而且|并且|还|也)",
|
||||
"更是", "无疑", "毫无疑问", "值得注意的是", "需要指出的是",
|
||||
"总而言之", "综上所述", "总的来说", "换言之", "众所周知",
|
||||
"一言以蔽之",
|
||||
],
|
||||
"对照模板": [
|
||||
r"不是.{0,40}而是", r"并非.{0,40}而是", "而不是",
|
||||
r"不先.{0,30}先", r"下一步是.{0,40}而不是",
|
||||
],
|
||||
"问答式标题": [
|
||||
r"回答了?[一二三四五六七八九十0-9]+个.{0,20}问题",
|
||||
r"以下[一二三四五六七八九十0-9]+[点项个]",
|
||||
r"[一二三四五六七八九十0-9]+个具体问题",
|
||||
r"结果说明了什么", r"需要关注的几个问题",
|
||||
],
|
||||
"实施口号": [
|
||||
r"先把.{0,30}接入", "从单一场景切入", "从一个场景开始",
|
||||
],
|
||||
"英文对照模板": [
|
||||
r"\bnot\b.{0,60}\bbut\b", r"\brather than\b", r"\binstead of\b",
|
||||
],
|
||||
"英文元话语": [
|
||||
r"answers? (three|four|[0-9]+)",
|
||||
"three specific questions", "three findings from",
|
||||
],
|
||||
"空心形容词": [
|
||||
"强大的", "卓越的", "高效的", "全方位", "一站式", "赋能", "助力",
|
||||
"打造", "深耕", "护航", "保驾护航", "量身定制", "极致", "无缝",
|
||||
],
|
||||
"宏大开头": [
|
||||
r"随着.{0,30}(的)?(快速)?发展", "在当今", "在数字化", "近年来",
|
||||
"大背景下",
|
||||
],
|
||||
"空洞升华": [
|
||||
"携手", "美好未来", "贡献力量", "必将", "奠定坚实基础", "开创",
|
||||
],
|
||||
"客服尾巴": [
|
||||
"希望能对您有所帮助", "如有需要", "欢迎随时", "感谢您的",
|
||||
],
|
||||
"绕弯结构": [
|
||||
r"进行了.{0,15}的?(操作|处理|优化|改造|分析)",
|
||||
r"做出了.{0,15}的?决定",
|
||||
r"起到了.{0,15}的?作用",
|
||||
r"具有.{0,20}的特点",
|
||||
r"实现了.{0,20}的功能",
|
||||
],
|
||||
"过度对冲": [
|
||||
"在某种程度上", "总体而言", "一般来说",
|
||||
],
|
||||
"格式痕迹": [
|
||||
"——", "✅", "❌", "🚀", "💡", "🎯", "✨", "🔥", "📌", "⚡",
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 复核项:结合页面类型与专业语境判断,不机械清零
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
REVIEW_ONLY = {
|
||||
"内部审校话语": [
|
||||
"当前基线", "当前证据", "已知缺口", "成熟度等级", "页面所述",
|
||||
],
|
||||
"抽象术语": [
|
||||
"闭环", "底座", "形成衔接", "共同约束",
|
||||
],
|
||||
"教程式CTA": [
|
||||
"说明当前流程", "从第一步开始", "按清单准备材料",
|
||||
],
|
||||
"标题口令": [
|
||||
r"^\s*#{1,6}\s*(把|让|先|再)",
|
||||
r"^\s*#{1,6}\s*(Bring|Start|Let|First)\b",
|
||||
],
|
||||
"否定句堆叠": [
|
||||
"不代表", "不包含", "尚未", "不能",
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 结构类近似检测:正则给候选,最终要人读
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STRUCTURE_CHECKS = {
|
||||
# 短句里塞了 4 个以上顿号/逗号,多半是三段式排比或名词堆叠
|
||||
"疑似排比堆叠": {
|
||||
"pattern": r"[,、]",
|
||||
"min_count": 4,
|
||||
"max_line_len": 60,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""中文正式文档「AI 味」扫描器。
|
||||
|
||||
用法:
|
||||
python3 deai_scan.py 方案.md
|
||||
python3 deai_scan.py docs/ --ext .md .html
|
||||
python3 deai_scan.py public/index.html --rules my_site
|
||||
python3 deai_scan.py 方案.md --json # 机器可读,供 agent 二次处理
|
||||
python3 deai_scan.py 方案.md --fail-on-block # 阻断项非零时退出码 1,可做门禁
|
||||
|
||||
Markdown 的代码块(``` 围栏与缩进块)和 HTML 的 <script>/<style>/<pre>/<code>
|
||||
默认跳过,避免把代码里的英文关键字误判成 AI 味。用 --no-skip-code 关闭。
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import bisect
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
RULES_DIR = os.path.join(SKILL_DIR, "project_rules")
|
||||
|
||||
LEVEL_BLOCK = "阻断"
|
||||
LEVEL_REVIEW = "复核"
|
||||
|
||||
|
||||
def _load_module(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _load_project_rule_data(path):
|
||||
"""Read project rules as literals without executing repository code."""
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
tree = ast.parse(handle.read(), filename=path)
|
||||
allowed = {"AI_SMELL", "REVIEW_ONLY", "REVIEW_LOG"}
|
||||
data = {}
|
||||
for node in tree.body:
|
||||
if (
|
||||
isinstance(node, ast.Expr)
|
||||
and isinstance(node.value, ast.Constant)
|
||||
and isinstance(node.value.value, str)
|
||||
):
|
||||
continue
|
||||
if (
|
||||
isinstance(node, ast.Assign)
|
||||
and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id in allowed
|
||||
):
|
||||
try:
|
||||
data[node.targets[0].id] = ast.literal_eval(node.value)
|
||||
except (ValueError, SyntaxError) as exc:
|
||||
raise ValueError(
|
||||
f"项目词典仅允许字面量:{path}"
|
||||
) from exc
|
||||
continue
|
||||
raise ValueError(
|
||||
f"项目词典包含不可执行的语句:{path}:{getattr(node, 'lineno', '?')}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def load_rules(project_rules=None):
|
||||
"""载入通用词典,可选叠加一个项目词典(同名分类合并,不覆盖)。"""
|
||||
base = _load_module(os.path.join(SKILL_DIR, "ai_smell_dict.py"), "ai_smell_dict")
|
||||
block = {k: list(v) for k, v in base.AI_SMELL.items()}
|
||||
review = {k: list(v) for k, v in base.REVIEW_ONLY.items()}
|
||||
structure = dict(getattr(base, "STRUCTURE_CHECKS", {}))
|
||||
|
||||
if project_rules:
|
||||
path = project_rules
|
||||
if not os.path.exists(path):
|
||||
path = os.path.join(RULES_DIR, f"{project_rules}.py")
|
||||
if not os.path.exists(path):
|
||||
available = [f[:-3] for f in sorted(os.listdir(RULES_DIR))
|
||||
if f.endswith(".py") and not f.startswith("_")]
|
||||
sys.exit(f"找不到项目词典 {project_rules!r};可用:{available or '(无)'}")
|
||||
proj = _load_project_rule_data(path)
|
||||
for cat, words in proj.get("AI_SMELL", {}).items():
|
||||
block.setdefault(cat, []).extend(words)
|
||||
for cat, words in proj.get("REVIEW_ONLY", {}).items():
|
||||
review.setdefault(cat, []).extend(words)
|
||||
|
||||
return block, review, structure
|
||||
|
||||
|
||||
def compile_rule_groups(groups):
|
||||
"""Compile rule groups once so directory scans do not recompile per file."""
|
||||
compiled = []
|
||||
for category, rules in groups.items():
|
||||
for rule in rules:
|
||||
try:
|
||||
pattern = re.compile(rule, re.I | re.M | re.S)
|
||||
except re.error as exc:
|
||||
raise ValueError(
|
||||
f"无效正则 {rule!r}({category}):{exc}"
|
||||
) from exc
|
||||
compiled.append((category, rule, pattern))
|
||||
return compiled
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 代码区屏蔽:把不参与扫描的区间用空格替换,保持行号与列偏移不变
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _blank_out(text, pattern, flags=re.S | re.I):
|
||||
def repl(m):
|
||||
return re.sub(r"[^\n]", " ", m.group(0))
|
||||
return re.sub(pattern, repl, text, flags=flags)
|
||||
|
||||
|
||||
def mask_code(text, path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".md", ".markdown"):
|
||||
text = _blank_out(text, r"```.*?```")
|
||||
text = _blank_out(text, r"~~~.*?~~~")
|
||||
text = _blank_out(text, r"(?m)^(?: {4}|\t).*$", flags=re.M)
|
||||
text = _blank_out(text, r"`[^`\n]+`", flags=0)
|
||||
elif ext in (".html", ".htm", ".xhtml"):
|
||||
for tag in ("script", "style", "pre", "code"):
|
||||
text = _blank_out(text, rf"<{tag}\b.*?</{tag}>")
|
||||
text = _blank_out(text, r"<!--.*?-->")
|
||||
return text
|
||||
|
||||
|
||||
def strip_html_tags(text):
|
||||
"""HTML 文件里把标签本身抹掉,只留可见文本,避免属性名命中词条。"""
|
||||
return _blank_out(text, r"<[^>]+>", flags=re.S)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 扫描
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_text(
|
||||
text,
|
||||
path,
|
||||
block,
|
||||
review,
|
||||
structure,
|
||||
skip_code=True,
|
||||
compiled_block=None,
|
||||
compiled_review=None,
|
||||
):
|
||||
scan_src = mask_code(text, path) if skip_code else text
|
||||
if os.path.splitext(path)[1].lower() in (".html", ".htm", ".xhtml"):
|
||||
scan_src = strip_html_tags(scan_src)
|
||||
|
||||
lines = scan_src.splitlines()
|
||||
raw_lines = text.splitlines()
|
||||
line_starts = [0]
|
||||
line_starts.extend(match.end() for match in re.finditer("\n", scan_src))
|
||||
findings = []
|
||||
|
||||
compiled_sets = (
|
||||
(LEVEL_BLOCK, compiled_block or compile_rule_groups(block)),
|
||||
(LEVEL_REVIEW, compiled_review or compile_rule_groups(review)),
|
||||
)
|
||||
for level, compiled in compiled_sets:
|
||||
for category, rule, pattern in compiled:
|
||||
for match in pattern.finditer(scan_src):
|
||||
line_number = bisect.bisect_right(line_starts, match.start())
|
||||
raw = (
|
||||
raw_lines[line_number - 1]
|
||||
if line_number <= len(raw_lines)
|
||||
else ""
|
||||
)
|
||||
findings.append({
|
||||
"file": path,
|
||||
"line": line_number,
|
||||
"level": level,
|
||||
"category": category,
|
||||
"rule": rule,
|
||||
"match": match.group(0),
|
||||
"excerpt": raw.strip()[:120],
|
||||
})
|
||||
|
||||
for cat, cfg in structure.items():
|
||||
rx = re.compile(cfg["pattern"])
|
||||
for i, line in enumerate(lines, 1):
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if len(rx.findall(s)) >= cfg.get("min_count", 4) and len(s) <= cfg.get("max_line_len", 60):
|
||||
findings.append({
|
||||
"file": path,
|
||||
"line": i,
|
||||
"level": LEVEL_REVIEW,
|
||||
"category": cat,
|
||||
"rule": cfg["pattern"],
|
||||
"match": "",
|
||||
"excerpt": s[:120],
|
||||
})
|
||||
|
||||
findings.sort(key=lambda f: (f["line"], f["level"] != LEVEL_BLOCK, f["category"]))
|
||||
return findings
|
||||
|
||||
|
||||
def collect_files(targets, exts):
|
||||
out = []
|
||||
for t in targets:
|
||||
if os.path.isdir(t):
|
||||
for root, _dirs, files in os.walk(t):
|
||||
_dirs[:] = [d for d in _dirs if d not in
|
||||
{".git", "node_modules", ".venv", "__pycache__", "dist", "build"}]
|
||||
for f in sorted(files):
|
||||
if os.path.splitext(f)[1].lower() in exts:
|
||||
out.append(os.path.join(root, f))
|
||||
elif os.path.exists(t):
|
||||
out.append(t)
|
||||
else:
|
||||
print(f"跳过不存在的路径:{t}", file=sys.stderr)
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="中文正式文档 AI 味扫描")
|
||||
ap.add_argument("targets", nargs="+", help="待扫描的文件或目录")
|
||||
ap.add_argument("--rules", help="项目词典名(project_rules/ 下的模块名)或路径")
|
||||
ap.add_argument("--ext", nargs="+", default=[".md", ".markdown", ".txt", ".html", ".htm"],
|
||||
help="目录递归时纳入的扩展名")
|
||||
ap.add_argument("--json", action="store_true", help="输出 JSON,供 agent 二次处理")
|
||||
ap.add_argument("--block-only", action="store_true", help="只报阻断项")
|
||||
ap.add_argument("--no-skip-code", action="store_true", help="不跳过代码块")
|
||||
ap.add_argument("--fail-on-block", action="store_true", help="存在阻断项时退出码 1")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
block, review, structure = load_rules(args.rules)
|
||||
compiled_block = compile_rule_groups(block)
|
||||
compiled_review = compile_rule_groups(review)
|
||||
except ValueError as exc:
|
||||
sys.exit(str(exc))
|
||||
exts = {e if e.startswith(".") else "." + e for e in args.ext}
|
||||
files = collect_files(args.targets, exts)
|
||||
if not files:
|
||||
sys.exit("没有可扫描的文件")
|
||||
|
||||
all_findings = []
|
||||
errors = []
|
||||
scanned_files = 0
|
||||
for path in files:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except (UnicodeDecodeError, OSError) as e:
|
||||
errors.append({"file": path, "error": str(e)})
|
||||
continue
|
||||
scanned_files += 1
|
||||
all_findings.extend(
|
||||
scan_text(
|
||||
text,
|
||||
path,
|
||||
block,
|
||||
review,
|
||||
structure,
|
||||
skip_code=not args.no_skip_code,
|
||||
compiled_block=compiled_block,
|
||||
compiled_review=compiled_review,
|
||||
)
|
||||
)
|
||||
|
||||
if args.block_only:
|
||||
all_findings = [f for f in all_findings if f["level"] == LEVEL_BLOCK]
|
||||
|
||||
n_block = sum(1 for f in all_findings if f["level"] == LEVEL_BLOCK)
|
||||
n_review = len(all_findings) - n_block
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({
|
||||
"files": scanned_files,
|
||||
"requested_files": len(files),
|
||||
"block": n_block,
|
||||
"review": n_review,
|
||||
"errors": errors,
|
||||
"findings": all_findings,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
cur = None
|
||||
for f in all_findings:
|
||||
if f["file"] != cur:
|
||||
cur = f["file"]
|
||||
print(f"\n=== {cur} ===")
|
||||
hit = f" ← {f['match']}" if f["match"] else ""
|
||||
print(f"[{f['level']}·{f['category']}] L{f['line']}: {f['excerpt']}{hit}")
|
||||
for error in errors:
|
||||
print(f"[读取失败] {error['file']}:{error['error']}", file=sys.stderr)
|
||||
print(f"\n扫描 {scanned_files}/{len(files)} 个文件:阻断项 {n_block},人工复核项 {n_review}")
|
||||
if n_block:
|
||||
print("阻断项须改写到接近 0;复核项结合页面类型与专业语境逐条判断,不机械清零。")
|
||||
|
||||
if errors:
|
||||
sys.exit(2)
|
||||
if args.fail_on_block and n_block:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""匿名项目词典模板。
|
||||
|
||||
复制为有意义的模块名后,用 ``--rules <模块名>`` 加载。项目规则只收录特定
|
||||
站点、文档类型或团队确认过的表达,跨项目通用规则应放回 ai_smell_dict.py。
|
||||
"""
|
||||
|
||||
AI_SMELL = {
|
||||
"项目禁用表达": [
|
||||
r"示例阻断词",
|
||||
],
|
||||
}
|
||||
|
||||
REVIEW_ONLY = {
|
||||
"项目复核表达": [
|
||||
r"示例复核词",
|
||||
],
|
||||
}
|
||||
|
||||
REVIEW_LOG = [
|
||||
{
|
||||
"date": "YYYY-MM-DD",
|
||||
"rejected": "原句",
|
||||
"issue": "评审意见",
|
||||
"fix": "最终改法",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SKILL_DIR))
|
||||
|
||||
import deai_scan
|
||||
|
||||
|
||||
class DeaiScanTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.block, cls.review, cls.structure = deai_scan.load_rules()
|
||||
|
||||
def scan(self, text, suffix=".md"):
|
||||
return deai_scan.scan_text(
|
||||
text,
|
||||
f"sample{suffix}",
|
||||
self.block,
|
||||
self.review,
|
||||
self.structure,
|
||||
)
|
||||
|
||||
def test_reports_block_and_review_findings(self):
|
||||
findings = self.scan("综上所述,方案提供一站式服务。\n当前基线需要人工复核。")
|
||||
levels = {finding["level"] for finding in findings}
|
||||
self.assertEqual(levels, {deai_scan.LEVEL_BLOCK, deai_scan.LEVEL_REVIEW})
|
||||
|
||||
def test_masks_markdown_code(self):
|
||||
findings = self.scan("正文没有问题。\n```text\n综上所述,打造闭环。\n```\n")
|
||||
self.assertEqual(findings, [])
|
||||
|
||||
def test_masks_html_code_and_attributes(self):
|
||||
findings = self.scan(
|
||||
'<div data-note="综上所述">正常正文</div>'
|
||||
"<script>const text = '一站式';</script>",
|
||||
".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()
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
id: document-writing
|
||||
name: 文档写作
|
||||
description: 协助起草结构清晰、语气专业的中文办公文档,并在信息不足时明确标注待确认内容。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 写作
|
||||
- 文档
|
||||
- 办公
|
||||
---
|
||||
|
||||
# 文档写作
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 先确认文档类型、目标读者、写作目的、语气和篇幅。
|
||||
- 仅依据用户提供的信息写作,不臆造事实、数据、引语或结论。
|
||||
- 信息缺失时使用“待确认”标记,并列出需要补充的问题。
|
||||
- 涉及隐私、机密或敏感信息时,提醒用户审阅并酌情脱敏。
|
||||
|
||||
## 推荐流程
|
||||
|
||||
1. 提炼核心目标与读者需要采取的行动。
|
||||
2. 设计“背景—要点—行动”或适合文体的结构。
|
||||
3. 使用简洁标题、短段落和一致术语完成初稿。
|
||||
4. 检查逻辑、事实边界、语气、格式与可读性。
|
||||
5. 输出成稿,并附简短的待确认事项。
|
||||
|
||||
## 输出要求
|
||||
|
||||
- 默认提供标题、正文和必要的小标题。
|
||||
- 重点结论前置,行动项写明负责人和时间要求(如已知)。
|
||||
- 避免空话、重复表达、夸张承诺和含混指代。
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
id: email-assistant
|
||||
name: 邮件助手
|
||||
description: 协助撰写、改写和回复专业邮件,突出目的、关键信息与明确行动项。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 邮件
|
||||
- 沟通
|
||||
- 办公
|
||||
---
|
||||
|
||||
# 邮件助手
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 明确收件人关系、邮件目的、期望行动、截止时间和语气。
|
||||
- 不编造姓名、职位、承诺、附件内容或已发生的沟通。
|
||||
- 对敏感信息、外部收件人和群发场景提示用户复核。
|
||||
- 避免施压、冒犯、歧义和不必要的冗长表达。
|
||||
|
||||
## 撰写流程
|
||||
|
||||
1. 用具体主题概括事项和所需行动。
|
||||
2. 开头直接说明背景与来意。
|
||||
3. 分点呈现事实、问题或请求。
|
||||
4. 明确下一步、负责人和时间(如已知)。
|
||||
5. 使用与关系和场景相符的结束语。
|
||||
|
||||
## 输出格式
|
||||
|
||||
- **主题:** 简短且可检索。
|
||||
- **正文:** 称呼、目的、要点、行动请求、结束语。
|
||||
- **待确认:** 列出缺失的收件人、日期、附件或事实。
|
||||
|
||||
回复邮件时,应区分已回答问题、尚待确认问题和新增行动项。
|
||||
@@ -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-dir>` 指本 `SKILL.md` 所在目录,不要假定技能安装在固定路径。
|
||||
|
||||
## 首次准备
|
||||
|
||||
先探测可用的 Python 3 解释器:Windows 优先使用 `python`,macOS/Linux
|
||||
优先使用 `python3`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
```bash
|
||||
<python> -m pip install -r "<skill-dir>/requirements.txt"
|
||||
cp "<skill-dir>/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
|
||||
<python> "<skill-dir>/diagram_kit.py" --check-font
|
||||
```
|
||||
|
||||
### 2. 构建 DOCX
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/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
|
||||
<python> "<skill-dir>/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
|
||||
<python> -m unittest discover -s "<skill-dir>/tests" -p "test_*.py"
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
"""matplotlib 架构图/流程图通用工具箱(中文可用)。
|
||||
|
||||
不用画图工具手绘,用脚本画方框和箭头:改文字就是改字符串;配色字体统一由
|
||||
常量控制;图表能进 git diff,方便 review 措辞变更。
|
||||
|
||||
用法:在你自己的 gen_diagrams.py 里
|
||||
import sys, os
|
||||
sys.path.insert(0, "<skill_dir>")
|
||||
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__)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -0,0 +1,19 @@
|
||||
# 一、章节标题
|
||||
|
||||
本章正文使用 Markdown 编写。图片路径相对于当前 Markdown 文件所在目录。
|
||||
|
||||
## 1. 二级标题
|
||||
|
||||
支持**粗体**、*斜体*、`行内代码`、列表和表格。
|
||||
|
||||
| 项目 | 说明 |
|
||||
|---|---|
|
||||
| 示例 | 表格会按内容长度分配列宽 |
|
||||
|
||||

|
||||
|
||||
*图 1 示例架构图*
|
||||
|
||||
```python
|
||||
print("围栏代码块会保留缩进和换行")
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
"\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"<th>{h}</th>" for h in headers)
|
||||
cells = "".join(f"<td>{c}</td>" for c in body)
|
||||
rows = BeautifulSoup(
|
||||
f"<table><tr>{head}</tr><tr>{cells}</tr></table>", "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(
|
||||
"说明文字 \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()
|
||||
@@ -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()
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
id: meeting-minutes
|
||||
name: 会议纪要
|
||||
description: 将用户提供的会议记录整理为客观、可追踪的纪要,明确结论、分歧与行动项。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 会议
|
||||
- 纪要
|
||||
- 协作
|
||||
---
|
||||
|
||||
# 会议纪要
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 忠实整理原始记录,不推测未明确表达的决定或责任。
|
||||
- 区分讨论内容、正式决策、待确认事项和行动项。
|
||||
- 保留关键分歧及其依据,避免将建议误写为结论。
|
||||
- 对姓名、日期、数字和专有名词进行一致性检查。
|
||||
|
||||
## 整理流程
|
||||
|
||||
1. 确认会议主题、时间、参会人和目标。
|
||||
2. 按议题归纳背景、讨论要点与结论。
|
||||
3. 提取每项行动的负责人、截止时间和交付物。
|
||||
4. 汇总未决问题、风险和后续会议需求。
|
||||
5. 标记原始记录中含糊或相互冲突的信息。
|
||||
|
||||
## 输出模板
|
||||
|
||||
- **会议信息:** 主题、时间、参会人
|
||||
- **会议目标:** 本次会议要解决的问题
|
||||
- **议题与结论:** 按议题分组
|
||||
- **行动项:** 事项、负责人、截止时间、状态
|
||||
- **待确认事项:** 缺失信息或未决问题
|
||||
|
||||
未提供的信息统一标注为“待确认”,不得自行补全。
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
id: presentation-outline
|
||||
name: 演示大纲
|
||||
description: 根据目标与受众设计逻辑清晰的演示文稿大纲,明确每页核心信息与叙事衔接。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 演示
|
||||
- 大纲
|
||||
- 表达
|
||||
---
|
||||
|
||||
# 演示大纲
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 先明确演示目的、受众、场合、时长和期望行动。
|
||||
- 每页聚焦一个核心信息,标题应直接表达结论。
|
||||
- 事实、数据与案例仅来自用户材料;缺少依据时标注待补充。
|
||||
- 控制信息密度,避免用大段文字代替口头讲解。
|
||||
|
||||
## 设计流程
|
||||
|
||||
1. 用一句话定义演示的核心主张。
|
||||
2. 选择适合目标的叙事结构,如“问题—分析—方案—行动”。
|
||||
3. 为每页写结论式标题、关键要点和建议视觉形式。
|
||||
4. 检查页面间逻辑、证据充分性和时间分配。
|
||||
5. 以明确总结和下一步行动收尾。
|
||||
|
||||
## 输出格式
|
||||
|
||||
按页输出:
|
||||
|
||||
- **页码与标题:** 结论式标题
|
||||
- **页面目的:** 该页要让受众理解什么
|
||||
- **关键内容:** 不超过五个要点
|
||||
- **视觉建议:** 图表、流程、时间线或重点数字
|
||||
- **讲述提示:** 与前后页面的衔接
|
||||
|
||||
另附开场、总结和待补充材料清单。
|
||||
@@ -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-dir>` 指本 `SKILL.md` 所在目录。
|
||||
|
||||
## 建立清单
|
||||
|
||||
```bash
|
||||
cp "<skill-dir>/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`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/scripts/validate_evidence.py" ./product-evidence.json
|
||||
<python> "<skill-dir>/scripts/validate_evidence.py" ./product-evidence.json --json
|
||||
<python> "<skill-dir>/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。
|
||||
|
||||
## 完成标准
|
||||
|
||||
清单结构校验通过;公开级别与使用渠道匹配;所有现有功能、参数和批准主张有
|
||||
证据;规划能力、限制和人工复核要求没有被省略;不含密钥、客户隐私或私有地址。
|
||||
@@ -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())
|
||||
@@ -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": [
|
||||
"绝对领先",
|
||||
"百分之百准确"
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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。
|
||||
- 当前能力、试用能力和规划能力明确分开。
|
||||
- 模块树无重复、孤立节点和伪造层级。
|
||||
- 功能粒度基本一致,名称、术语和版本范围统一。
|
||||
- 限制条件没有因表格精简而丢失。
|
||||
@@ -0,0 +1,27 @@
|
||||
# {{产品名称}}功能列表
|
||||
|
||||
**产品版本**:{{版本}}
|
||||
**适用范围**:{{版本/部署方式/授权范围}}
|
||||
**清单日期**:{{YYYY-MM-DD}}
|
||||
|
||||
## 功能目录
|
||||
|
||||
| 一级模块 | 二级模块 | 功能名称 | 使用角色 | 用户动作与结果 | 版本范围 | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| {{模块}} | {{子模块}} | {{对象+动作}} | {{角色}} | {{触发动作;系统行为;可观察结果}} | {{版本}} | released |
|
||||
|
||||
## 版本能力矩阵
|
||||
|
||||
| 功能 ID | 标准版 | 专业版 | 企业版 | 依赖条件 |
|
||||
|---|---:|---:|---:|---|
|
||||
| {{FEAT-001}} | ✓ | ✓ | ✓ | {{依赖}} |
|
||||
|
||||
## 限制与说明
|
||||
|
||||
- {{功能 ID}}:{{适用边界、依赖或人工复核要求}}。
|
||||
|
||||
## 内部追溯表
|
||||
|
||||
| 功能 ID | 功能名称 | 状态 | 证据 ID | 复核结果 |
|
||||
|---|---|---|---|---|
|
||||
| {{FEAT-001}} | {{功能}} | released | {{EVD-001}} | {{通过/待核验}} |
|
||||
@@ -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`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
```bash
|
||||
<python> "<skill-dir>/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
|
||||
<python> "<skill-dir>/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` 指向中间产物,交付门禁将只校验中间产物而放行缺失
|
||||
的真实交付物。
|
||||
|
||||
## 完成标准
|
||||
|
||||
路由计划通过校验;所有必需节点完成;无失败依赖被忽略;各产物共享同一事实版本;
|
||||
跨产物一致性、保密审查和人工审批全部通过,并保留选择、跳过和阻断理由;交付目录
|
||||
中每个产物都以约定格式实际存在,且已按密级分区。
|
||||
@@ -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())
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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% 缩放时仍可读;读者在一分钟内能说出产品定位、核心能力、适用范围
|
||||
和下一步;所有数字和主张有证据;没有因压缩而删除关键限制。
|
||||
@@ -0,0 +1,37 @@
|
||||
# {{产品名称}}
|
||||
|
||||
## {{面向目标角色,提供具体产品能力}}
|
||||
|
||||
{{一句话说明产品类别、主要用途和适用范围。}}
|
||||
|
||||
### 目标场景
|
||||
|
||||
- {{角色需要完成的具体任务}}
|
||||
- {{现有流程中的可验证问题}}
|
||||
|
||||
### 核心能力
|
||||
|
||||
| 能力 | 用户动作与结果 |
|
||||
|---|---|
|
||||
| {{能力一}} | {{动作;结果}} |
|
||||
| {{能力二}} | {{动作;结果}} |
|
||||
| {{能力三}} | {{动作;结果}} |
|
||||
|
||||
### 工作方式
|
||||
|
||||
{{一张架构图、流程图或三步工作流。}}
|
||||
|
||||
### 已核验参数或结果
|
||||
|
||||
- **{{数值}}**:{{指标、版本、条件与口径}}
|
||||
- **{{数值}}**:{{指标、版本、条件与口径}}
|
||||
|
||||
### 部署与适用范围
|
||||
|
||||
{{部署方式、数据条件、依赖、限制和人工复核要求。}}
|
||||
|
||||
### 下一步
|
||||
|
||||
{{项目咨询 / 申请演示 / 获取技术规格}}
|
||||
|
||||
<!-- 内部追溯:列出本页使用的 FEAT/PAR/CLM/EVD ID,发布前删除此注释。 -->
|
||||
@@ -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`。下文 `<python>` 表示探测成功的解释器命令。
|
||||
|
||||
复制并填写 `templates/deck.example.json`:
|
||||
|
||||
```bash
|
||||
<python> -m pip install -r "<skill-dir>/requirements.txt"
|
||||
<python> "<skill-dir>/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 可打开;页数和时长符合简报;每页目标明确;视觉层级统一;备注完整;无
|
||||
未经授权的客户信息、竞品结论或内部证据路径。
|
||||
@@ -0,0 +1 @@
|
||||
python-pptx>=1.0,<2
|
||||
@@ -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()
|
||||
@@ -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": "给出明确、可执行的后续动作。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
id: project-planning
|
||||
name: 项目规划
|
||||
description: 将项目目标拆解为范围、里程碑、任务、责任、风险与验收标准,形成可执行计划。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 项目
|
||||
- 计划
|
||||
- 管理
|
||||
---
|
||||
|
||||
# 项目规划
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 先明确目标、成功标准、范围边界、约束和关键相关方。
|
||||
- 不虚构资源、工期、预算或团队承诺。
|
||||
- 计划应体现任务依赖、决策节点和必要缓冲。
|
||||
- 风险需描述触发条件、影响、负责人和应对措施。
|
||||
|
||||
## 规划流程
|
||||
|
||||
1. 将项目目标转化为可验收的交付物。
|
||||
2. 明确范围内事项、范围外事项和关键假设。
|
||||
3. 拆解阶段、里程碑、任务及其依赖关系。
|
||||
4. 为任务指定负责人、时间和完成标准(如已知)。
|
||||
5. 评估风险、资源缺口、沟通机制和变更方式。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **项目概述:** 背景、目标与成功标准
|
||||
- **范围:** 包含、不包含与假设
|
||||
- **里程碑:** 交付物、目标日期与验收标准
|
||||
- **任务计划:** 任务、负责人、依赖、时间与状态
|
||||
- **风险登记:** 风险、概率、影响与应对
|
||||
- **治理机制:** 汇报节奏、决策人与变更流程
|
||||
|
||||
未知信息标注“待确认”,并说明其对计划可靠性的影响。
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
id: proofreading
|
||||
name: 文本校对
|
||||
description: 系统检查文本的错别字、语法、标点、格式与一致性,并在不改变原意的前提下提出修订。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 校对
|
||||
- 编辑
|
||||
- 质量
|
||||
---
|
||||
|
||||
# 文本校对
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 以保留作者原意、事实和语气为首要目标。
|
||||
- 区分确定错误、风格建议和需要作者确认的内容。
|
||||
- 不擅自改动数字、专有名词、引用、承诺或结论。
|
||||
- 修改应保持全文术语、格式和标点规则一致。
|
||||
|
||||
## 校对流程
|
||||
|
||||
1. 确认文本用途、目标读者和采用的语言规范。
|
||||
2. 检查错别字、语法、搭配、标点和病句。
|
||||
3. 检查标题层级、编号、空格、日期与数字格式。
|
||||
4. 检查术语、人名、缩写和指代的一致性。
|
||||
5. 复核修改是否引入新歧义或改变原意。
|
||||
|
||||
## 输出方式
|
||||
|
||||
- **清洁版:** 已修正明确错误的完整文本。
|
||||
- **修改说明:** 汇总影响含义或结构的主要调整。
|
||||
- **待确认项:** 列出歧义、事实疑点或多种可接受写法。
|
||||
|
||||
纯风格调整应克制;如用户只要求找错,不主动重写全文。
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
id: requirements-analysis
|
||||
name: 需求分析
|
||||
description: 将业务诉求整理为边界明确、可验证、可追踪的需求,识别歧义、依赖与验收条件。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 需求
|
||||
- 分析
|
||||
- 验收
|
||||
---
|
||||
|
||||
# 需求分析
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 区分业务目标、用户问题、解决方案设想和正式需求。
|
||||
- 不替相关方决定未确认的优先级、范围或业务规则。
|
||||
- 每项需求应明确对象、触发条件、预期行为和验收结果。
|
||||
- 主动识别歧义、冲突、异常场景、依赖与非功能要求。
|
||||
|
||||
## 分析流程
|
||||
|
||||
1. 明确目标用户、业务目标和衡量成功的指标。
|
||||
2. 梳理现状、痛点、范围边界与关键术语。
|
||||
3. 将诉求拆成独立、可验证的功能需求。
|
||||
4. 补充权限、数据、性能、可用性和合规等约束。
|
||||
5. 定义验收标准,并建立需求与目标的对应关系。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **背景与目标:** 问题、用户与预期价值
|
||||
- **范围:** 包含、不包含与假设
|
||||
- **功能需求:** 编号、描述、优先级与依赖
|
||||
- **业务规则:** 条件、例外与边界
|
||||
- **非功能需求:** 质量属性与约束
|
||||
- **验收标准:** 可观察、可判断的结果
|
||||
- **待确认问题:** 歧义、冲突与决策人
|
||||
|
||||
所有推断均标注为“假设”,未经确认不得写成既定要求。
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
id: research-synthesis
|
||||
name: 研究综合
|
||||
description: 综合用户提供的研究材料,比较观点与证据,形成可追溯、平衡且边界清晰的结论。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 研究
|
||||
- 综合
|
||||
- 证据
|
||||
---
|
||||
|
||||
# 研究综合
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 仅综合用户提供的材料,不声称查阅了未提供的信息。
|
||||
- 清楚区分材料中的事实、作者观点、推论和自身归纳。
|
||||
- 保留来源标识,使关键结论可追溯到具体材料。
|
||||
- 同时呈现一致观点、分歧、证据缺口和适用边界。
|
||||
|
||||
## 综合流程
|
||||
|
||||
1. 明确研究问题、范围和评价标准。
|
||||
2. 按主题整理各材料的主张、证据与方法。
|
||||
3. 比较一致性、冲突点、证据强弱和时间适用性。
|
||||
4. 提炼跨材料模式,并检查是否存在反例。
|
||||
5. 形成有限度的结论及进一步研究问题。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **研究问题:** 范围与目标
|
||||
- **材料概览:** 每份材料的主题与证据类型
|
||||
- **主题综合:** 共识、差异与关联
|
||||
- **证据评估:** 强项、局限与潜在偏差
|
||||
- **综合结论:** 结论、置信边界与适用条件
|
||||
- **待研究问题:** 现有材料无法回答的事项
|
||||
|
||||
引用或转述时保留用户材料中的来源名称,不伪造出处。
|
||||
@@ -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、技术方案保持一致。
|
||||
|
||||
## 完成标准
|
||||
|
||||
演示能在时间预算内重复执行;关键步骤有备用路径;失败不会暴露敏感信息或破坏
|
||||
环境;操作脚本、讲解词和事实证据一致;现场人员知道何时停止、切换备用和重置。
|
||||
@@ -0,0 +1,49 @@
|
||||
# {{产品/场景}}演示套件
|
||||
|
||||
## 一、演示目标
|
||||
|
||||
- 受众:{{角色}}
|
||||
- 目标:{{希望受众理解或同意的事项}}
|
||||
- 时长:{{分钟}}
|
||||
- 产品版本:{{版本}}
|
||||
- 不展示范围:{{范围}}
|
||||
|
||||
## 二、环境与数据
|
||||
|
||||
| 项目 | 要求 | 检查方法 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 演示环境 | {{环境,不写真实密钥}} | {{检查命令或页面}} | 待检查 |
|
||||
| 账号角色 | {{角色}} | {{权限确认}} | 待检查 |
|
||||
| 示例数据 | {{匿名数据集}} | {{完整性检查}} | 待检查 |
|
||||
|
||||
## 三、演示故事线
|
||||
|
||||
| 时间 | 阶段 | 目的 | 讲解重点 |
|
||||
|---:|---|---|---|
|
||||
| 0-2 分钟 | 场景说明 | {{目的}} | {{重点}} |
|
||||
|
||||
## 四、逐步运行手册
|
||||
|
||||
### STEP-01 {{步骤名称}}
|
||||
|
||||
- 前置状态:{{状态}}
|
||||
- 操作:{{具体动作}}
|
||||
- 预期结果:{{可观察结果}}
|
||||
- 事实依据:{{FEAT/CLM ID}}
|
||||
- 讲解词:{{简短讲解}}
|
||||
- 失败判断:{{何时判定失败}}
|
||||
- 回退:{{备用页面、截图或替代步骤}}
|
||||
|
||||
## 五、常见问答
|
||||
|
||||
| 问题 | 回答要点 | 依据 | 不应承诺 |
|
||||
|---|---|---|---|
|
||||
| {{问题}} | {{回答}} | {{证据 ID}} | {{边界}} |
|
||||
|
||||
## 六、重置与现场检查
|
||||
|
||||
- [ ] 清理上次演示数据。
|
||||
- [ ] 恢复初始账号和权限。
|
||||
- [ ] 验证网络、依赖和备用材料。
|
||||
- [ ] 确认屏幕无通知、密钥和客户信息。
|
||||
- [ ] 完整计时演练并记录问题。
|
||||
@@ -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 执行高分辨率视觉复核。
|
||||
|
||||
## 完成标准
|
||||
|
||||
研究问题得到回答;术语统一;关键结论有来源;测试可复核;架构图与正文一致;
|
||||
限制、依赖和适用范围完整;参考文献无缺失、重复或无法定位条目。
|
||||
@@ -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 | 通过 |
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
id: spreadsheet-analysis
|
||||
name: 表格分析
|
||||
description: 基于用户提供的表格内容规划分析方法,识别数据质量问题并形成可解释的业务结论。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 表格
|
||||
- 数据分析
|
||||
- 洞察
|
||||
---
|
||||
|
||||
# 表格分析
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 先确认分析目标、字段含义、时间范围、单位和统计口径。
|
||||
- 不猜测缺失值、异常值或字段关系,不将相关性表述为因果性。
|
||||
- 明确区分原始数据、计算结果、解释和建议。
|
||||
- 涉及个人或敏感数据时,建议最小化使用并进行脱敏。
|
||||
|
||||
## 分析流程
|
||||
|
||||
1. 盘点工作表、字段、数据类型与记录范围。
|
||||
2. 检查缺失、重复、异常、口径冲突和格式不一致。
|
||||
3. 根据问题选择汇总、分组、对比、趋势或分布分析。
|
||||
4. 记录计算定义、筛选条件和必要假设。
|
||||
5. 提炼证据充分的发现、局限与后续验证建议。
|
||||
|
||||
## 输出结构
|
||||
|
||||
- **分析目标:** 要回答的业务问题
|
||||
- **数据概况:** 范围、字段、口径与质量
|
||||
- **分析方法:** 分组维度、指标定义与假设
|
||||
- **关键发现:** 结论及对应证据
|
||||
- **限制与风险:** 数据不足或偏差来源
|
||||
- **建议:** 可验证、可执行的下一步
|
||||
|
||||
对无法从现有数据支持的结论,应明确说明“证据不足”。
|
||||
@@ -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。
|
||||
|
||||
## 完成标准
|
||||
|
||||
需求无遗漏;架构、功能、实施、交付和验收闭合;事实与产品版本一致;图表和编号
|
||||
连续;假设、偏离、风险和非范围项明确;所有数字和承诺可定位到输入依据。
|
||||
@@ -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}} | {{交付物}} | {{方法}} | 已覆盖 |
|
||||
@@ -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` 有证据;偏离与澄清未被隐藏;条款编号、
|
||||
阈值和方案引用准确;矩阵与最终技术方案使用相同版本和术语。
|
||||
@@ -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 | 是 |
|
||||
|
||||
## 四、覆盖统计
|
||||
|
||||
- 原始要求数:{{数量}}
|
||||
- 已拆分子要求数:{{数量}}
|
||||
- 完全满足:{{数量}}
|
||||
- 部分满足:{{数量}}
|
||||
- 不满足:{{数量}}
|
||||
- 待澄清:{{数量}}
|
||||
@@ -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. 参数到事实证据的内部追溯表。
|
||||
|
||||
## 风险检查
|
||||
|
||||
- 是否把规划能力写成强制现有参数。
|
||||
- 是否为体现“先进”而编造性能阈值。
|
||||
- 是否把特定品牌或架构写成唯一实现路径,造成不必要排他性。
|
||||
- 是否存在无法复现的“高、快、强、稳定”等主观指标。
|
||||
- 是否遗漏测试数据、环境、并发模型、持续时间或误差范围。
|
||||
|
||||
## 完成标准
|
||||
|
||||
每项要求具备唯一编号、级别、参数内容、适用条件、验收方法和证据;全文无互相
|
||||
冲突的阈值;待确认项没有混入正式参数;公开与保密边界符合输入约束。
|
||||
@@ -0,0 +1,32 @@
|
||||
# {{项目名称}}招标技术规格
|
||||
|
||||
## 一、采购范围与适用条件
|
||||
|
||||
- 采购对象:{{产品/服务范围}}
|
||||
- 部署环境:{{环境}}
|
||||
- 适用版本:{{版本}}
|
||||
- 验收阶段:{{阶段}}
|
||||
|
||||
## 二、技术参数
|
||||
|
||||
| 编号 | 分类 | 级别 | 技术要求 | 条件与口径 | 验收方法 | 证据/材料 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| TP-001 | 功能能力 | 强制 | 系统应{{可验证行为}} | {{版本、环境或前提}} | {{操作、测量或材料审查}} | {{EVD-001}} |
|
||||
|
||||
## 三、交付与服务要求
|
||||
|
||||
| 编号 | 级别 | 要求 | 验收方式 |
|
||||
|---|---|---|---|
|
||||
| SV-001 | 强制 | {{交付物、培训或服务要求}} | {{材料或现场验收}} |
|
||||
|
||||
## 四、待确认事项
|
||||
|
||||
| 编号 | 待确认内容 | 缺少依据 | 责任方 | 截止时间 |
|
||||
|---|---|---|---|---|
|
||||
| TBD-001 | {{参数或范围}} | {{需要补充的证据}} | {{责任方}} | {{日期}} |
|
||||
|
||||
## 五、内部追溯表
|
||||
|
||||
| 参数编号 | 事实/主张 ID | 证据 ID | 复核结论 |
|
||||
|---|---|---|---|
|
||||
| TP-001 | {{CLM-001/PAR-001}} | {{EVD-001}} | {{通过/待确认}} |
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
id: translation-polish
|
||||
name: 翻译润色
|
||||
description: 在忠实保留原意、事实与格式的前提下完成翻译或润色,使表达自然、专业且符合目标语境。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 翻译
|
||||
- 润色
|
||||
- 语言
|
||||
---
|
||||
|
||||
# 翻译润色
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 确认源语言、目标语言、读者、场景、语气和术语偏好。
|
||||
- 忠实保留事实、数字、日期、专有名词与不确定性。
|
||||
- 不擅自增删立场、承诺、限定条件或法律含义。
|
||||
- 对多义词、文化特定表达和术语冲突标注备选译法。
|
||||
|
||||
## 处理流程
|
||||
|
||||
1. 理解全文目的、上下文和语域。
|
||||
2. 建立关键术语及固定译法。
|
||||
3. 逐段转换含义,优先保证准确与连贯。
|
||||
4. 调整句式、语气和标点,使目标语言自然。
|
||||
5. 对照原文复核遗漏、误译、数字和格式。
|
||||
|
||||
## 输出方式
|
||||
|
||||
- 默认提供润色后的完整文本。
|
||||
- 存在关键歧义时,附“译法说明”与简短理由。
|
||||
- 用户要求对照时,按段落展示原文与译文。
|
||||
- 无法确认的术语或专名保留原文并标记“待确认”。
|
||||
|
||||
对于合同、医疗或其他高风险文本,应提醒用户进行专业复核。
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
id: weekly-report
|
||||
name: 周报整理
|
||||
description: 将零散工作记录整理为结果导向的周报,呈现进展、价值、风险与下周计划。
|
||||
version: 1.0.0
|
||||
tags:
|
||||
- 周报
|
||||
- 汇报
|
||||
- 进展
|
||||
---
|
||||
|
||||
# 周报整理
|
||||
|
||||
## 工作原则
|
||||
|
||||
- 优先呈现已完成结果及其影响,而非简单罗列活动。
|
||||
- 仅使用用户提供的数据,不夸大进度、效果或完成度。
|
||||
- 明确区分已完成、进行中、受阻和计划事项。
|
||||
- 风险描述应客观,并给出已知的应对方案或支持需求。
|
||||
|
||||
## 整理流程
|
||||
|
||||
1. 按目标或项目归类本周记录。
|
||||
2. 将过程描述改写为“行动—结果—影响”。
|
||||
3. 提取里程碑、关键数据、风险和依赖。
|
||||
4. 按优先级排列下周计划。
|
||||
5. 检查时间范围、状态和数据口径是否一致。
|
||||
|
||||
## 输出模板
|
||||
|
||||
### 本周成果
|
||||
|
||||
- 目标、完成结果及业务或团队影响。
|
||||
|
||||
### 进行中事项
|
||||
|
||||
- 当前状态、下一步与预计节点(如已知)。
|
||||
|
||||
### 风险与支持需求
|
||||
|
||||
- 风险、影响、应对措施和所需支持。
|
||||
|
||||
### 下周计划
|
||||
|
||||
- 按优先级列出目标、交付物与关键节点。
|
||||
|
||||
不确定的信息标注“待确认”,避免使用模糊的完成度表述。
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<CapabilityService> {
|
||||
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))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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<string[]> {
|
||||
if (await pathExists(join(root, 'SKILL.md'))) {
|
||||
return [root]
|
||||
}
|
||||
const found: string[] = []
|
||||
const walk = async (current: string, depth: number): Promise<void> => {
|
||||
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<string> {
|
||||
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<CapabilitySnapshot> {
|
||||
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<string> {
|
||||
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(
|
||||
|
||||
+1
-4
@@ -346,10 +346,7 @@ if (hasSingleInstanceLock) {
|
||||
): Promise<AgentRuntime> => {
|
||||
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([]),
|
||||
|
||||
Reference in New Issue
Block a user