feat: improve skill import and runtime delivery
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user