@topmindspace/topmind-skills 4.14.0 → 4.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/evals/evals.json +1 -1
- package/package.json +1 -1
- package/shared/auto-suggest.md +3 -1
- package/shared/long-url-capture.md +3 -0
- package/shared/trigger-disambiguation.md +2 -0
- package/topmind/SKILL.md +2 -2
- package/topmind/references/multi-intent.md +2 -2
- package/topmind-capture/SKILL.md +5 -3
- package/topmind-ledger/SKILL.md +1 -1
- package/topmind-loop/SKILL.md +1 -1
- package/topmind-maintain/SKILL.md +1 -1
- package/topmind-memory/SKILL.md +1 -1
- package/topmind-organize/SKILL.md +1 -1
- package/topmind-pack.json +11 -5
- package/topmind-wechat/SKILL.md +15 -4
- package/topmind-wechat/references/workflow.md +13 -2
- package/topmind-wechat/references/writing-quality.md +11 -3
- package/topmind-wechat/scripts/audit-provenance.py +366 -0
- package/topmind-wechat/scripts/scan_ai_flavor.py +71 -434
- package/topmind-weread/SKILL.md +1 -1
- package/topmind-write/SKILL.md +1 -1
- package/topmind-x/SKILL.md +1 -1
package/evals/evals.json
CHANGED
package/package.json
CHANGED
package/shared/auto-suggest.md
CHANGED
|
@@ -65,8 +65,10 @@ Host 能力映射:
|
|
|
65
65
|
| 能力 | Desktop | UTR | 纯 Host Agent |
|
|
66
66
|
|------|---------|-----|---------------|
|
|
67
67
|
| 提取 | `add_todo` · `todo_maintain` | `memory.add-todo` | 读活动窗口 → 识别行动语 → 写 checklist |
|
|
68
|
-
| 维护 | `toggle_todo` · `maintainTodos` | `memory.toggle-todo` | 对照周期本与 todo.md 做 diff 建议 |
|
|
68
|
+
| 维护 | `toggle_todo` · `update_todo` · `set_todo_due` · `delete_todo` · `maintainTodos` | `memory.toggle-todo` | 对照周期本与 todo.md 做 diff 建议 |
|
|
69
69
|
| 列表 | `list_todos` · Todo 面板 | `memory.list-todos` | 读 `memory/todo.md` 活跃段 |
|
|
70
|
+
| 动态查询 | `list_recent_stream` | `workspace-read.list-recent-captures` | 读最近周期本日段 |
|
|
71
|
+
| 记忆查询 | `list_recent_memories` | `memory.list-profile` 等 | 读 `memory/` 活跃段(勿整库倾倒) |
|
|
70
72
|
| 分类/专题建议 | `topic_classify`(确认后 `create_topic`) | 文件工具 + role 路由 | 按 role + 内容性质给最多 3 候选 |
|
|
71
73
|
| 记忆建议 | `memory_organize`(append/update/retire 建议) | `memory.append-profile` 等(确认后) | 识别稳定事实 → L1 候选 |
|
|
72
74
|
|
|
@@ -74,6 +74,9 @@ word_count: 1200
|
|
|
74
74
|
- URL 类型提示(GitHub README / GitHub 原文 / X / 网页)
|
|
75
75
|
- Inbox:按 `source_type` 筛选「网页/摘录」
|
|
76
76
|
- AI `fetch_url` 支持 `maxLen` + `render`;Obsidian `fetch_url` 对 GitHub md 自动走 raw
|
|
77
|
+
- AI `capture_url`:一键抓取并入库(带 source URL + 标题);Desktop 与 Obsidian 对齐(`forceInbox` / `forceAtom`)
|
|
78
|
+
- AI `web_search`:无需 Key 的网络搜索短列表(域名打分 + 同域去重);命中后 `fetch_url` / `capture_url`
|
|
79
|
+
- 工具失败会返回**一条**具体恢复步骤(如 edit 失败 → 先 read_file 刷 contentHash)
|
|
77
80
|
|
|
78
81
|
## 不要做的事
|
|
79
82
|
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|--------|--------------|------|
|
|
10
10
|
| 总结要点 | organize vs memory | 默认 → `topmind-organize`。仅「更新我的情况 / 加到专题记忆 / 沉淀到记忆」→ memory |
|
|
11
11
|
| 沉淀 | capture vs memory | 默认 → `topmind-capture`。仅「沉淀成稳定结论 / 更新我的情况」→ memory |
|
|
12
|
+
| **记一下 / Note it** | capture 主入口 | **完整捕获**(笔记 / 链接 / 附件)→ `topmind-capture` |
|
|
13
|
+
| **记下 / Log it** | capture(追加) | **写入当前周期本**(动态追加,同文件续写)→ `topmind-capture`,默认平铺到当前周期;不弹分类访谈 |
|
|
12
14
|
| 记一下要做 X / 别忘了 X | capture vs todo | → `topmind-capture`(材料落盘)+ 待办写入/建议 `memory/todo.md`(见 `auto-suggest.md`) |
|
|
13
15
|
| 待办 / 有什么要做的 | router 直达 | 读 `memory/todo.md` 活跃项;不新建概念、不进 organize |
|
|
14
16
|
| X 做完了 / 这条不用了 | todo vs organize | 清单项 → 勾掉/归档 `memory/todo.md`;整周事项状态 → organize「整理本周」 |
|
package/topmind/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: topmind
|
|
3
|
-
version: 4.
|
|
3
|
+
version: 4.15.0
|
|
4
4
|
description: >-
|
|
5
5
|
topmind 总入口与多意图路由(类别/专题/笔记/待办/交付)。Use when 用户说 topmind、意图模糊、
|
|
6
6
|
待办/有什么要做的/todo list、或需要收→整→写 分步。单意图明确时直接用 topmind-capture|organize|write|memory|maintain|loop|weread|x|ledger|wechat。
|
|
@@ -139,7 +139,7 @@ Connector:[`references/connector-resolution.md`](references/connector-resoluti
|
|
|
139
139
|
主路径:host 文件工具 + project-model-brief。Skills pack **不依赖** Pi / `pi-agent-core`;不要编造 bash / shell。宿主若有唯一片段替换,中段改稿优先用它。
|
|
140
140
|
降级:[`../shared/capability-degradation.md`](../shared/capability-degradation.md)。
|
|
141
141
|
|
|
142
|
-
UTR 可选(MCP primary+danger 共
|
|
142
|
+
UTR 可选(MCP primary+danger 共 29;注册表 38 = 8 域 / 38 命令,见 TOOLS.md):`list-categories` · `list-topics` · `inspect-topic` · `list-topic-files` · `list-inbox` · `create-topic` · `capture-note` · `save-output` · `contract.validate` · `contract.reseed` · `memory.promote` · `memory.digest` · `memory.append-profile` · `memory.append-topic` · `memory.retire-profile` · `memory.update-profile` · `memory.compact-history` · `memory.restore-profile` · `memory.list-todos` · `memory.add-todo` · `memory.toggle-todo` · `memory.update-todo` · `memory.set-todo-due` · `memory.delete-todo` · `doctor-workspace` · `plan-inbox-routing` · `archive-topic` · `archive-stream-year` · `restore-safety-receipt`。
|
|
143
143
|
|
|
144
144
|
字段始终独立 **`category` + `topic`**(真实目录名,非写死编号)。
|
|
145
145
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Multi-Intent Resolution
|
|
2
2
|
|
|
3
|
-
当用户请求包含多个意图时,按以下矩阵裁决(与
|
|
3
|
+
当用户请求包含多个意图时,按以下矩阵裁决(与 `../../shared/trigger-disambiguation.md` 一致):
|
|
4
4
|
|
|
5
5
|
| 意图组合 | 裁决 | 说明 |
|
|
6
6
|
|---------|------|------|
|
|
7
7
|
| capture + organize | 先 capture,回执中建议 organize | 先确保材料落地 |
|
|
8
8
|
| capture + memory | 先 capture,回执中建议 memory | 先存笔记再更新我的情况 / 周期反思 |
|
|
9
|
-
| capture + todo | 先 capture;显式行动语同轮写 `memory/todo.md`,隐含则 L1 建议 | 见
|
|
9
|
+
| capture + todo | 先 capture;显式行动语同轮写 `memory/todo.md`,隐含则 L1 建议 | 见 `../../shared/auto-suggest.md` |
|
|
10
10
|
| organize + write | 先 organize 提炼,再 write 输出 | 分两步 |
|
|
11
11
|
| organize + memory | 先 organize 候选,再 memory 落盘 | organize 产出列表 |
|
|
12
12
|
| organize + todo | 整理本周后 L1 待办 diff 建议 | 不自动批量改清单 |
|
package/topmind-capture/SKILL.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: topmind-capture
|
|
3
|
-
version: 4.
|
|
3
|
+
version: 4.15.0
|
|
4
4
|
description: >-
|
|
5
|
-
把链接、摘录、随手记收进动态周期本、Inbox 或专题;行动语自动提取/建议待办。Use when
|
|
6
|
-
|
|
5
|
+
把链接、摘录、随手记收进动态周期本、Inbox 或专题;行动语自动提取/建议待办。Use when 记一下、
|
|
6
|
+
收进、剪藏、保存链接、记一下要做/别忘了、capture、save URL/idea、Note it。
|
|
7
|
+
记下/Log it 是动态主区「写入当前周期本」——路由仍进本 skill,但不作 frontmatter 触发词(见 shared/trigger-disambiguation.md)。
|
|
7
8
|
Do NOT use for 整理本周、出稿、写我的情况、doctor/loop、微信读书、X.
|
|
8
9
|
action_category: capture
|
|
9
10
|
triggers:
|
|
@@ -30,6 +31,7 @@ triggers:
|
|
|
30
31
|
- capture
|
|
31
32
|
- note
|
|
32
33
|
- save
|
|
34
|
+
- "Note it"
|
|
33
35
|
tags: [capture, inbox, note, material]
|
|
34
36
|
entrypoint: false
|
|
35
37
|
compatibility: topmind workspace. Host file tools or optional UTR capture-note.
|
package/topmind-ledger/SKILL.md
CHANGED
package/topmind-loop/SKILL.md
CHANGED
package/topmind-memory/SKILL.md
CHANGED
package/topmind-pack.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"label": "topmind",
|
|
5
5
|
"description": "topmind skill pack: single daily entry topmind + progressive disclosure (shared/ + references/), Agent Skills open-standard aligned, capture-first template-driven categories, optional connectors topmind-weread and topmind-x, optional topmind-ledger (记账), optional write sub-skill topmind-wechat. None of the optional skills is an extra user concept. File-system-first content truth; UTR optional.",
|
|
6
6
|
"daily_entry": "topmind",
|
|
7
|
-
"version": "4.
|
|
7
|
+
"version": "4.15.0",
|
|
8
8
|
"updated": "2026-09-28",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"locales": {
|
|
@@ -382,7 +382,10 @@
|
|
|
382
382
|
"restore-profile",
|
|
383
383
|
"list-todos",
|
|
384
384
|
"add-todo",
|
|
385
|
-
"toggle-todo"
|
|
385
|
+
"toggle-todo",
|
|
386
|
+
"update-todo",
|
|
387
|
+
"set-todo-due",
|
|
388
|
+
"delete-todo"
|
|
386
389
|
],
|
|
387
390
|
"lifecycle": [
|
|
388
391
|
"scan"
|
|
@@ -391,8 +394,8 @@
|
|
|
391
394
|
"rebuild"
|
|
392
395
|
]
|
|
393
396
|
},
|
|
394
|
-
"command_count":
|
|
395
|
-
"mcp_default_count":
|
|
397
|
+
"command_count": 38,
|
|
398
|
+
"mcp_default_count": 29,
|
|
396
399
|
"command_exposure": {
|
|
397
400
|
"primary": [
|
|
398
401
|
"workspace-read.list-categories",
|
|
@@ -416,7 +419,10 @@
|
|
|
416
419
|
"memory.restore-profile",
|
|
417
420
|
"memory.list-todos",
|
|
418
421
|
"memory.add-todo",
|
|
419
|
-
"memory.toggle-todo"
|
|
422
|
+
"memory.toggle-todo",
|
|
423
|
+
"memory.update-todo",
|
|
424
|
+
"memory.set-todo-due",
|
|
425
|
+
"memory.delete-todo"
|
|
420
426
|
],
|
|
421
427
|
"danger": [
|
|
422
428
|
"workspace-maintain.archive-topic",
|
package/topmind-wechat/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: topmind-wechat
|
|
3
|
-
version: 4.
|
|
3
|
+
version: 4.15.0
|
|
4
4
|
description: >-
|
|
5
5
|
公众号文章全生命周期子技能(write 族):交付包、审校改写、质量三关、状态同步、微信内联排版与发布清单。
|
|
6
6
|
支持 forward(底稿→公众号)、reverse(选题原创→回推)、站外拉取(在线精选站→reverse+pending)三条路径;
|
|
@@ -49,14 +49,15 @@ compatibility: topmind workspace. Writes via UTR workspace-write / Desktop Works
|
|
|
49
49
|
| `scripts/push-to-topstream.py` | 可选回推:公众号稿降级为纯 Markdown notes |
|
|
50
50
|
| `scripts/lint-wechat.py` | 排版体检 + 自动修复(中英文间距、段长、AI 腔…) |
|
|
51
51
|
| `scripts/md2wechat.py` | Markdown → 全内联 HTML;**必加 `--embed-images`** |
|
|
52
|
-
| `scripts/scan_ai_flavor.py` | 中文去 AI
|
|
52
|
+
| `scripts/scan_ai_flavor.py` | 中文去 AI 味扫描 —— **转发器**,实现在 canonical `qu-aiwei-zh`(`--which` 可查解析结果;本目录不含实现,避免副本漂移) |
|
|
53
|
+
| `scripts/audit-provenance.py` | **改稿保真审计**:双向逐段溯源(本稿→源稿 / 源稿→本稿),抓悄悄扩写与被落下的段。有源稿时定稿必跑 |
|
|
53
54
|
|
|
54
55
|
```bash
|
|
55
56
|
# 路径解析:CLI --base/--workspace → env TOPMIND_WECHAT_BASE / TOPMIND_WORKSPACE / TOPSTREAM_ROOT → 惯例
|
|
56
57
|
export TOPMIND_WORKSPACE=/path/to/workspace # 推荐
|
|
57
58
|
python3 scripts/new-article.py --slug demo --title "标题" --direction reverse
|
|
58
59
|
python3 scripts/lint-wechat.py --input <包>/公众号稿.md --fix
|
|
59
|
-
python3 scripts/scan_ai_flavor.py <包>/公众号稿.md #
|
|
60
|
+
python3 scripts/scan_ai_flavor.py <包>/公众号稿.md # 总分与作者姿态分都 ≥85
|
|
60
61
|
python3 scripts/md2wechat.py --input <包>/公众号稿.md --out-dir <包> --slug demo \
|
|
61
62
|
--asset-root <素材根> --embed-images
|
|
62
63
|
python3 scripts/sync-status.py --set 定稿 <包> --apply
|
|
@@ -147,6 +148,13 @@ python3 scripts/sync-status.py --set 定稿 <包> --apply
|
|
|
147
148
|
- 改稿续写:正文已有数字**回源重核**(上一轮文本最不可信)
|
|
148
149
|
- 多口径(主轮/复跑)显式拆开;表格从数据源生成,禁止手抄
|
|
149
150
|
- 外部工具改过的稿:**先核数字再动文字**;「比值对但绝对值错」= 全段重核
|
|
151
|
+
- **有源稿时(`reverse` / 站外拉取)跑一次双向溯源审计**,别靠「我记得只改了三处」:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
python3 scripts/audit-provenance.py --draft <包>/公众号稿.md --source <包>/源稿-*.md
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
正向查「本稿有、源稿找不到」(扩写藏在这里),反向查「源稿有、本稿落下」。**退出码 1 不是失败,是「有待判定项」**——每一项要么归入已声明的修正/增补,要么就是不该有的改动;逐条在包内 README 声明后即收口。源稿里后加的块(勘误等)用 `--skip '^>\s*\[!'` 排除。
|
|
150
158
|
|
|
151
159
|
### 关 2 · 逻辑
|
|
152
160
|
|
|
@@ -156,13 +164,16 @@ python3 scripts/sync-status.py --set 定稿 <包> --apply
|
|
|
156
164
|
### 关 3 · 文字(去 AI 味)
|
|
157
165
|
|
|
158
166
|
```bash
|
|
159
|
-
python3 scripts/scan_ai_flavor.py <包>/公众号稿.md # ≥85(人话)
|
|
167
|
+
python3 scripts/scan_ai_flavor.py <包>/公众号稿.md # 总分 ≥85(人话)
|
|
160
168
|
```
|
|
161
169
|
|
|
170
|
+
- **同时看「作者姿态分」(E 类,与总分独立)**,目标同样 ≥85。实测两者会严重背离:同一篇 AI 完整版总分 92 / 姿态 21,手改发布版 100 / 100——**总分只差 8 分,姿态差 79 分**,只看总分会被骗
|
|
171
|
+
- ⚠️ 这里调的是 canonical `qu-aiwei-zh` 的脚本(本目录的 `scripts/scan_ai_flavor.py` 是转发器)。**若输出里没有「作者姿态层」一节,说明拿到的是 E 类之前的旧版本**,分数不可信——先 `--which` 确认实际执行的是哪一份
|
|
162
172
|
- 删套话/黑话/工程圈行话;降调段末加粗金句
|
|
163
173
|
- **满分 ≠ 有人味**:再查「段末金句癖 / 节奏过分整齐 / 没有场景与我 / 报告体标注」
|
|
164
174
|
- **口语化 ≠ 有人味**:删社交垫词(元叙述、空转过渡、姿态句)
|
|
165
175
|
- 判据:**这句话删掉之后,读者少知道了什么?**
|
|
176
|
+
- **姿态分只对文章有效**:清单 / 词表 / 说明书不适用(检测器会把被引用的反例当真命中),脚本命中 ≥70% 落在表格行时会提示「疑似清单文档」,看到提示先确认再改
|
|
166
177
|
|
|
167
178
|
详见 [`references/writing-quality.md`](references/writing-quality.md)。
|
|
168
179
|
|
|
@@ -60,21 +60,32 @@ python3 scripts/sync-status.py --set 定稿 <包> --apply
|
|
|
60
60
|
|
|
61
61
|
| 输出 | 口径 | 用途 |
|
|
62
62
|
|------|------|------|
|
|
63
|
-
| frontmatter `word_count` |
|
|
63
|
+
| frontmatter `word_count` | 正文**全文**的 `[\u4e00-\u9fff]` 计数 —— **frontmatter 之后的一切都算,含图注、也含图片路径里的中文** | 映射真源 |
|
|
64
64
|
| lint「字数」 | 视觉长度(中 1 + 英数 ×0.6,URL 不计) | 阅读时长 / 段长 |
|
|
65
65
|
| md2wechat「正文字数」 | 渲染后字符 | 仅展示 |
|
|
66
66
|
|
|
67
|
+
⚠️ `word_count` **只能由 `sync-status.py --apply` 回写,禁止手算**。它的口径与直觉不同:一篇 9 图稿,光 `images/NN-中文名.png` 这一串路名就贡献 40+ 字。自制口径(如「剔掉图注与路径」)会得出一个更小、更像「正文字数」的值,写进 frontmatter 后会被 `sync-mapping.py` 判为漂移 —— 实测一篇 1,027 vs 1,450,而错值同时落进 `topic.md` 总表与包内 README,**一个错值三处落盘**。
|
|
68
|
+
|
|
67
69
|
## 定稿验收命令串
|
|
68
70
|
|
|
69
71
|
```bash
|
|
70
|
-
python3 scripts/scan_ai_flavor.py <包>/公众号稿.md # ≥85
|
|
72
|
+
python3 scripts/scan_ai_flavor.py <包>/公众号稿.md # 总分 ≥85 且「作者姿态分」≥85
|
|
71
73
|
python3 scripts/lint-wechat.py --input <包>/公众号稿.md
|
|
74
|
+
python3 scripts/audit-provenance.py --draft <包>/公众号稿.md --source <包>/源稿-*.md
|
|
72
75
|
python3 scripts/sync-mapping.py --no-topstream
|
|
73
76
|
python3 scripts/sync-status.py
|
|
74
77
|
python3 scripts/md2wechat.py --input <包>/公众号稿.md --out-dir <包> \
|
|
75
78
|
--slug <slug> --asset-root <素材根> --embed-images
|
|
76
79
|
```
|
|
77
80
|
|
|
81
|
+
第 3 行只在**有源稿**时跑(`reverse` / 站外拉取;`forward` 的底稿在 `notes/` 也一样适用)。退出码 **0 = 双向干净**、**1 = 有待判定项**、2 = 参数错。**1 不等于稿子有问题**——它只是把「本稿有源稿找不到」和「源稿有本稿落下」两类段摆出来,由人逐条判:归入已声明的修正/增补,或改回去。判定完把结论写进包内 `README.md`,这一关才算收口。
|
|
82
|
+
|
|
83
|
+
三个易踩的点:
|
|
84
|
+
|
|
85
|
+
- **阈值别乱调**:默认 `0.72`。调高会把正常的改写(换词、调序)判成新增,调低会漏掉真扩写。
|
|
86
|
+
- **源稿后加的块要 `--skip`**:若源稿顶部有本包后补的勘误/说明块(`> [!note] …`),它在本稿侧不存在,会恒定报一条反向待判定。用 `--skip '^>\s*\[!'` 排除,别去改稿子。
|
|
87
|
+
- **图注不算遗漏**:脚本反向侧额外建了一个「去掉 `▲ 图 N` 前缀」的候选池,图注编号不会被误判为漏段。
|
|
88
|
+
|
|
78
89
|
## 收尾
|
|
79
90
|
|
|
80
91
|
1. 更新专题 `topic.md` 总表(派生视图)
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|---|---|---|
|
|
45
45
|
| 中文长文 / 公众号改稿 | `humanizer-zh` + `qu-aiwei-zh` | 先立边界,再扫描定位(默认组合) |
|
|
46
46
|
| 只要分数和命中清单 | `qu-aiwei-zh` | 跑脚本,不改稿 |
|
|
47
|
-
| 随笔要「加声音」 | `qu-aiwei-zh` + `references/pan-style.md` | 用表达方式加,不编故事 |
|
|
47
|
+
| 随笔要「加声音」 | `qu-aiwei-zh` + `qu-aiwei-zh/references/pan-style.md` | 用表达方式加,不编故事 |
|
|
48
48
|
| 英文文本 | `humanizer` | `humanizer-zh` 只针对中文 |
|
|
49
49
|
| 清单 / 词表 / 说明书 | 都不用 E 类姿态分 | 这类文体靠列举反例,检测器会把反例当真命中 |
|
|
50
50
|
|
|
@@ -70,12 +70,20 @@
|
|
|
70
70
|
|
|
71
71
|
### 第二步:扫描定位(`qu-aiwei-zh`)
|
|
72
72
|
|
|
73
|
-
目标:`qu-aiwei-zh` 扫描
|
|
73
|
+
目标:`qu-aiwei-zh` 扫描 **总分 ≥ 85**,且**「作者姿态分」(E 类)也 ≥ 85**。两个分数独立,都要看。
|
|
74
74
|
|
|
75
75
|
```bash
|
|
76
|
+
python3 scripts/scan_ai_flavor.py <稿>.md
|
|
77
|
+
# 或直连 canonical(同一个脚本)
|
|
76
78
|
python3 ~/.workbuddy/skills/qu-aiwei-zh/scripts/scan_ai_flavor.py <稿>.md
|
|
77
79
|
```
|
|
78
80
|
|
|
81
|
+
`scripts/scan_ai_flavor.py` 是**转发器**,实现在 canonical `qu-aiwei-zh`,两条命令等价(`--which` 可查解析到哪一份)。
|
|
82
|
+
|
|
83
|
+
⚠️ **认准「作者姿态层」这一节**:姿态分是后加的维度,早期版本没有。若输出里没有这一节,说明调用到了旧版脚本,**分数不可信**。实测同一篇稿:AI 完整版总分 92 / 姿态 21,手改发布版 100 / 100 —— 总分只差 8 分,姿态差 79 分,**只看总分会被骗**。
|
|
84
|
+
|
|
85
|
+
⚠️ **姿态分只对文章有效**:清单 / 词表 / 说明书不适用——这类文体靠列举反例工作,检测器会把被引用的例子当成真命中。脚本命中 ≥70% 落在表格行时会提示「疑似清单文档」,看到提示先确认文体再改。
|
|
86
|
+
|
|
79
87
|
### 删套话
|
|
80
88
|
|
|
81
89
|
值得注意 / 值得记录 / 值得单独强调 / 本质上 / 事实上 / 意味着 / 由此可见 / 不难看出 / 换句话说 / 换言之 / 殊途同归 / 显而易见 / 众所周知 / 更进一步说
|
|
@@ -114,7 +122,7 @@ python3 ~/.workbuddy/skills/qu-aiwei-zh/scripts/scan_ai_flavor.py <稿>.md
|
|
|
114
122
|
|
|
115
123
|
有「我」、有具体场景、有具体数字与人名、有不确定性表达(「其实」「不过」「还在想」)、句子长短交替、有真实细节而不是「很多人」「大多数」。
|
|
116
124
|
|
|
117
|
-
⚠️ 这一段说的是**表达方式**。原文没有第一人称、没有数字时,不许靠这一条去补——那正好踩中 `humanizer-zh` 的优先级第 1 条。加声音的手段是句长节奏、设问、括号自注(见 `pan-style.md`),配合删掉元叙述和教学腔。`humanizer-zh` 里写着同一句话:「用户要求增强个人风格时,用表达方式实现,不编故事。」
|
|
125
|
+
⚠️ 这一段说的是**表达方式**。原文没有第一人称、没有数字时,不许靠这一条去补——那正好踩中 `humanizer-zh` 的优先级第 1 条。加声音的手段是句长节奏、设问、括号自注(见 `qu-aiwei-zh/references/pan-style.md`),配合删掉元叙述和教学腔。`humanizer-zh` 里写着同一句话:「用户要求增强个人风格时,用表达方式实现,不编故事。」
|
|
118
126
|
|
|
119
127
|
英文文本才走 `humanizer`:它的取向是 **Add soul**(主动注入个性),与中文改稿的**保真优先**方向相反,别把它的思路套到中文稿上。
|
|
120
128
|
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""audit-provenance.py — 双向逐段溯源审计(改稿保真度的机械化检查)。
|
|
4
|
+
|
|
5
|
+
## 它解决什么问题
|
|
6
|
+
|
|
7
|
+
把一篇成稿改成另一种形态(X 长文 → 公众号、底稿 → 交付稿)时,最容易出的不是错别字,
|
|
8
|
+
而是**悄悄扩写**和**悄悄漏掉**。人眼逐段比对一篇几千字的稿子既慢又不可靠。
|
|
9
|
+
|
|
10
|
+
本脚本做**两个方向**的机械化检查:
|
|
11
|
+
|
|
12
|
+
- **正向(本稿 → 源稿)**:`--draft` 的每一段,能否在 `--source` 里找到?找不到的即
|
|
13
|
+
「疑似新增」—— 扩写就藏在这里
|
|
14
|
+
- **反向(源稿 → 本稿)**:`--source` 的每一段,有没有在本稿里落下?找不到的即「疑似遗漏」
|
|
15
|
+
|
|
16
|
+
## 为什么不用 diff
|
|
17
|
+
|
|
18
|
+
形态转换必然改变排版:断段、标题层级、`++` 标记层、图注加 `▲ 图 N` 编号。
|
|
19
|
+
逐行 diff 会被这些噪声淹没,真正的问题(多出/少掉的事实句)反而看不见。
|
|
20
|
+
|
|
21
|
+
所以这里**先归一化到「纯文字内容」再比对**,并显式放过几类合法差异:
|
|
22
|
+
|
|
23
|
+
| 分类 | 含义 |
|
|
24
|
+
|------|------|
|
|
25
|
+
| `identical` | 归一化后完全相同 |
|
|
26
|
+
| `rewritten` | 同段但有文字改动(图注编号、标点、已声明的事实修正都落这里) |
|
|
27
|
+
| `merged` | 本稿把源稿的**几段并成了一段** |
|
|
28
|
+
| `split` | 源稿的一段被本稿**拆成了几段** |
|
|
29
|
+
| `caption` | 仅图注加了 `▲ 图 N:` 前缀,正文一字未改 |
|
|
30
|
+
| `tail` | 结尾 CTA / 签名 / 「首发于公众号」类附加句 |
|
|
31
|
+
| `unexplained` | **其余全部**,需要人工判定 —— 这是唯一会被报警的一类 |
|
|
32
|
+
|
|
33
|
+
⚠️ **`merged` / `split` 两类是必须的**:源稿常把「正文末句 + 图注」挤在同一段,本稿拆开是
|
|
34
|
+
正常排版。没有这两类,几乎每张图都会报一次假阳性;而只做段落级匹配则会把所有拆段都误报。
|
|
35
|
+
所以候选池同时建两级窗口:**段窗口**(1~3 段合并)与**句窗口**(1~3 句合并,长度下限 12 字)。
|
|
36
|
+
|
|
37
|
+
## 已知陷阱(踩过)
|
|
38
|
+
|
|
39
|
+
不要用 `^---.*?^---` 配 `re.S` 剥 frontmatter —— 正文里常有 `---` 分隔线,
|
|
40
|
+
那个正则会连带把中间正文吃掉,导致源稿被误判成「只剩几段」。
|
|
41
|
+
本脚本只剥**文件头**的 frontmatter:`^---\\n.*?\\n---\\n` + `count=1`。
|
|
42
|
+
|
|
43
|
+
源稿里后加的块(勘误、HTML 注释)默认会进待判定,用 `--skip` 排除:
|
|
44
|
+
|
|
45
|
+
--skip '^>\\s*\\[!'
|
|
46
|
+
|
|
47
|
+
## 用法
|
|
48
|
+
|
|
49
|
+
python3 scripts/audit-provenance.py --draft <包>/公众号稿.md --source <包>/源稿-X长文.md
|
|
50
|
+
python3 scripts/audit-provenance.py --draft a.md --source b.md --skip '^>\\s*\\[!' --json
|
|
51
|
+
python3 scripts/audit-provenance.py --draft a.md --source b.md --threshold 0.8 --verbose
|
|
52
|
+
|
|
53
|
+
退出码:0 = 无待判定项;1 = 有 `unexplained`(需人工看);2 = 参数/文件错误。
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import argparse
|
|
59
|
+
import json
|
|
60
|
+
import re
|
|
61
|
+
import sys
|
|
62
|
+
from difflib import SequenceMatcher
|
|
63
|
+
|
|
64
|
+
MAX_SPAN = 3 # 允许 1~MAX_SPAN 个相邻单位合并成一个窗口
|
|
65
|
+
MIN_SENT_WIN = 12 # 句级窗口归一化后的最小长度,避免短句偶然命中
|
|
66
|
+
|
|
67
|
+
# 元信息块(来源 / 抓取记录)——两侧都跳过
|
|
68
|
+
META_RE = re.compile(
|
|
69
|
+
r"^>\s*(来源|实标题|标题|摘要|作者|站点|提取|约字数|说明|发布|原始)\s*[::]"
|
|
70
|
+
)
|
|
71
|
+
IMG_ONLY_RE = re.compile(r"^\s*(!\[[^\]]*\]\([^)]*\)|!\[\[[^\]]*\]\])\s*$")
|
|
72
|
+
HTML_COMMENT_RE = re.compile(r"^\s*<!--.*?-->\s*$", re.S)
|
|
73
|
+
CAPTION_PREFIX_RE = re.compile(r"^▲\s*图\s*\d+\s*[::\-–—]?\s*")
|
|
74
|
+
SENT_RE = re.compile(r"[^。!?;!?;]*[。!?;!?;]|[^。!?;!?;]+")
|
|
75
|
+
|
|
76
|
+
TAIL_HINTS = ("欢迎转发", "点个关注", "评论区", "不迷路", "首发于", "转载请注明",
|
|
77
|
+
"如果这篇", "如果对你有用")
|
|
78
|
+
|
|
79
|
+
_TYPO = {
|
|
80
|
+
"\u201c": '"', "\u201d": '"', "\u2018": "'", "\u2019": "'",
|
|
81
|
+
"\u2014": "-", "\u2013": "-", "\u2026": "...",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- #
|
|
86
|
+
# 切段与归一化
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
|
|
89
|
+
def strip_fm(text: str) -> str:
|
|
90
|
+
"""只剥文件头的 frontmatter。正文里的 --- 必须保留。"""
|
|
91
|
+
if text.startswith("---\n"):
|
|
92
|
+
m = re.match(r"^---\n.*?\n---\n", text, re.S | re.M)
|
|
93
|
+
if m:
|
|
94
|
+
return text[m.end():]
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def blocks(text: str, skips):
|
|
99
|
+
"""按空行切段;丢掉分隔线、纯图片、元信息块、HTML 注释块、--skip 命中的块。
|
|
100
|
+
|
|
101
|
+
代码块内部不切分,避免把一段代码拆散。
|
|
102
|
+
"""
|
|
103
|
+
body = strip_fm(text).replace("\r\n", "\n")
|
|
104
|
+
out, buf, in_code = [], [], False
|
|
105
|
+
for line in body.split("\n"):
|
|
106
|
+
if line.strip().startswith("```"):
|
|
107
|
+
in_code = not in_code
|
|
108
|
+
buf.append(line)
|
|
109
|
+
continue
|
|
110
|
+
if in_code:
|
|
111
|
+
buf.append(line)
|
|
112
|
+
continue
|
|
113
|
+
if line.strip() == "":
|
|
114
|
+
if buf:
|
|
115
|
+
out.append("\n".join(buf))
|
|
116
|
+
buf = []
|
|
117
|
+
else:
|
|
118
|
+
buf.append(line)
|
|
119
|
+
if buf:
|
|
120
|
+
out.append("\n".join(buf))
|
|
121
|
+
|
|
122
|
+
keep = []
|
|
123
|
+
for b in out:
|
|
124
|
+
s = b.strip()
|
|
125
|
+
if not s or re.fullmatch(r"[-*_]{3,}", s) or IMG_ONLY_RE.match(s):
|
|
126
|
+
continue
|
|
127
|
+
if META_RE.match(s) or HTML_COMMENT_RE.match(s):
|
|
128
|
+
continue
|
|
129
|
+
if any(rx.search(s) for rx in skips):
|
|
130
|
+
continue
|
|
131
|
+
keep.append(s)
|
|
132
|
+
return keep
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def normalize(block: str) -> str:
|
|
136
|
+
"""把一段 markdown 归一化成「可比较的纯文字内容」。"""
|
|
137
|
+
t = block
|
|
138
|
+
t = re.sub(r"^\s*>\s?", "", t, flags=re.M)
|
|
139
|
+
t = re.sub(r"^\s*#{1,6}\s*", "", t, flags=re.M)
|
|
140
|
+
t = re.sub(r"^\s*[-*+]\s+", "", t, flags=re.M)
|
|
141
|
+
t = re.sub(r"^\s*\d+[.)]\s+", "", t, flags=re.M)
|
|
142
|
+
t = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", t)
|
|
143
|
+
t = re.sub(r"!\[\[[^\]]*\]\]", "", t)
|
|
144
|
+
t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t)
|
|
145
|
+
t = re.sub(r"`{1,3}", "", t)
|
|
146
|
+
for a in ("**", "__", "==", "++", "~~"):
|
|
147
|
+
t = t.replace(a, "")
|
|
148
|
+
for a, b in _TYPO.items():
|
|
149
|
+
t = t.replace(a, b)
|
|
150
|
+
return re.sub(r"\s+", "", t)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def block_windows(norms):
|
|
154
|
+
"""段级窗口:(起始段, 结束段, 合并文本)。"""
|
|
155
|
+
n = len(norms)
|
|
156
|
+
for i in range(n):
|
|
157
|
+
acc = ""
|
|
158
|
+
for k in range(MAX_SPAN):
|
|
159
|
+
if i + k >= n:
|
|
160
|
+
break
|
|
161
|
+
acc += norms[i + k]
|
|
162
|
+
yield i, i + k, acc
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def sent_windows(norms):
|
|
166
|
+
"""句级窗口:(所属段, 所属段, 合并文本)。用于识别「源稿一段被拆开」。"""
|
|
167
|
+
for i, blk in enumerate(norms):
|
|
168
|
+
sents = [s for s in SENT_RE.findall(blk) if s]
|
|
169
|
+
for a in range(len(sents)):
|
|
170
|
+
acc = ""
|
|
171
|
+
for k in range(MAX_SPAN):
|
|
172
|
+
if a + k >= len(sents):
|
|
173
|
+
break
|
|
174
|
+
acc += sents[a + k]
|
|
175
|
+
if len(acc) >= MIN_SENT_WIN:
|
|
176
|
+
yield i, i, acc
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def best(needle, win_list):
|
|
180
|
+
"""返回 (最高相似度, 窗口起, 窗口止, 次高相似度)。"""
|
|
181
|
+
if not needle or not win_list:
|
|
182
|
+
return 0.0, -1, -1, 0.0
|
|
183
|
+
scored = []
|
|
184
|
+
for a, b, txt in win_list:
|
|
185
|
+
scored.append((SequenceMatcher(None, needle, txt).ratio(), a, b))
|
|
186
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
187
|
+
top = scored[0]
|
|
188
|
+
second = scored[1][0] if len(scored) > 1 else 0.0
|
|
189
|
+
return top[0], top[1], top[2], second
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def is_tail(text: str) -> bool:
|
|
193
|
+
return any(h in text for h in TAIL_HINTS)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def snippet(text: str, n: int = 44) -> str:
|
|
197
|
+
one = re.sub(r"\s+", " ", text).strip()
|
|
198
|
+
return one if len(one) <= n else one[:n] + "…"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --------------------------------------------------------------------------- #
|
|
202
|
+
# 审计
|
|
203
|
+
# --------------------------------------------------------------------------- #
|
|
204
|
+
|
|
205
|
+
def audit(draft_path, source_path, threshold, skips):
|
|
206
|
+
dblocks = blocks(open(draft_path, encoding="utf-8").read(), skips)
|
|
207
|
+
sblocks = blocks(open(source_path, encoding="utf-8").read(), skips)
|
|
208
|
+
dn = [normalize(b) for b in dblocks]
|
|
209
|
+
sn = [normalize(b) for b in sblocks]
|
|
210
|
+
|
|
211
|
+
s_blk = list(block_windows(sn))
|
|
212
|
+
s_sent = list(sent_windows(sn))
|
|
213
|
+
# 本稿侧额外把「去掉 ▲ 图 N 前缀」的版本算作候选,图注不算遗漏
|
|
214
|
+
dn_plus = list(dn) + [normalize(CAPTION_PREFIX_RE.sub("", b.strip()))
|
|
215
|
+
for b in dblocks if CAPTION_PREFIX_RE.match(b.strip())]
|
|
216
|
+
dn_plus = [x for x in dn_plus if x]
|
|
217
|
+
d_blk = list(block_windows(dn_plus))
|
|
218
|
+
d_sent = list(sent_windows(dn_plus))
|
|
219
|
+
|
|
220
|
+
def judge(r_blk, r_sent, span_blk, span_sent):
|
|
221
|
+
"""按「先段级、后句级」判定分类。"""
|
|
222
|
+
if r_blk >= threshold:
|
|
223
|
+
if r_blk >= 0.995:
|
|
224
|
+
return "identical", r_blk, span_blk
|
|
225
|
+
if span_blk[1] > span_blk[0]:
|
|
226
|
+
return "merged", r_blk, span_blk
|
|
227
|
+
return "rewritten", r_blk, span_blk
|
|
228
|
+
if r_sent >= threshold:
|
|
229
|
+
return "split", r_sent, span_sent
|
|
230
|
+
return "unexplained", max(r_blk, r_sent), span_blk
|
|
231
|
+
|
|
232
|
+
forward = []
|
|
233
|
+
for i, d in enumerate(dblocks):
|
|
234
|
+
if not dn[i]:
|
|
235
|
+
continue
|
|
236
|
+
rb, ab1, ab2, sb = best(dn[i], s_blk)
|
|
237
|
+
rs, as1, as2, ss = best(dn[i], s_sent)
|
|
238
|
+
capped = bool(CAPTION_PREFIX_RE.match(d.strip()))
|
|
239
|
+
r_nocap = -1.0
|
|
240
|
+
if capped:
|
|
241
|
+
alt = normalize(CAPTION_PREFIX_RE.sub("", d.strip()))
|
|
242
|
+
if alt:
|
|
243
|
+
r_nocap, *_ = best(alt, s_blk)
|
|
244
|
+
top = max(rb, rs, r_nocap)
|
|
245
|
+
|
|
246
|
+
if top >= threshold:
|
|
247
|
+
kind, _, span = judge(rb, rs, (ab1, ab2), (as1, as2))
|
|
248
|
+
if capped and r_nocap > max(rb, rs):
|
|
249
|
+
kind = "caption"
|
|
250
|
+
forward.append({"i": i, "kind": kind, "ratio": round(top, 3),
|
|
251
|
+
"source_span": list(span), "text": snippet(d)})
|
|
252
|
+
elif is_tail(d):
|
|
253
|
+
forward.append({"i": i, "kind": "tail", "ratio": round(top, 3),
|
|
254
|
+
"source_span": [-1, -1], "text": snippet(d)})
|
|
255
|
+
else:
|
|
256
|
+
forward.append({"i": i, "kind": "unexplained", "ratio": round(top, 3),
|
|
257
|
+
"second": round(max(sb, ss), 3), "source_span": [ab1, ab2],
|
|
258
|
+
"text": snippet(d)})
|
|
259
|
+
|
|
260
|
+
backward = []
|
|
261
|
+
for i, s in enumerate(sblocks):
|
|
262
|
+
if not sn[i]:
|
|
263
|
+
continue
|
|
264
|
+
rb, ab1, ab2, sb = best(sn[i], d_blk)
|
|
265
|
+
rs, as1, as2, ss = best(sn[i], d_sent)
|
|
266
|
+
kind, ratio, span = judge(rb, rs, (ab1, ab2), (as1, as2))
|
|
267
|
+
if kind == "identical":
|
|
268
|
+
kind = "found"
|
|
269
|
+
rec = {"i": i, "kind": kind, "ratio": round(ratio, 3),
|
|
270
|
+
"draft_span": list(span), "text": snippet(s)}
|
|
271
|
+
if kind == "unexplained":
|
|
272
|
+
rec["second"] = round(max(sb, ss), 3)
|
|
273
|
+
backward.append(rec)
|
|
274
|
+
|
|
275
|
+
return {"draft": draft_path, "source": source_path, "threshold": threshold,
|
|
276
|
+
"draft_blocks": len(dblocks), "source_blocks": len(sblocks),
|
|
277
|
+
"forward": forward, "backward": backward}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def report(res) -> int:
|
|
281
|
+
f, b = res["forward"], res["backward"]
|
|
282
|
+
f_un = [x for x in f if x["kind"] == "unexplained"]
|
|
283
|
+
b_un = [x for x in b if x["kind"] == "unexplained"]
|
|
284
|
+
|
|
285
|
+
print("溯源审计:%s ← %s" % (res["draft"], res["source"]))
|
|
286
|
+
print("阈值 %.2f · 本稿 %d 段 · 源稿 %d 段" %
|
|
287
|
+
(res["threshold"], res["draft_blocks"], res["source_blocks"]))
|
|
288
|
+
print("-" * 78)
|
|
289
|
+
|
|
290
|
+
def tally(rows):
|
|
291
|
+
d = {}
|
|
292
|
+
for r in rows:
|
|
293
|
+
d[r["kind"]] = d.get(r["kind"], 0) + 1
|
|
294
|
+
return d
|
|
295
|
+
|
|
296
|
+
print("正向(本稿 → 源稿):", " ".join("%s %d" % kv for kv in sorted(tally(f).items())))
|
|
297
|
+
print("反向(源稿 → 本稿):", " ".join("%s %d" % kv for kv in sorted(tally(b).items())))
|
|
298
|
+
|
|
299
|
+
if f_un:
|
|
300
|
+
print("\n⚠ 正向待判定(本稿有、源稿找不到 —— 扩写藏在这里):")
|
|
301
|
+
for r in f_un:
|
|
302
|
+
print(" 段%-4d 相似度 %.2f(次高 %.2f) 「%s」" %
|
|
303
|
+
(r["i"], r["ratio"], r.get("second", 0), r["text"]))
|
|
304
|
+
if b_un:
|
|
305
|
+
print("\n⚠ 反向待判定(源稿有、本稿落下了):")
|
|
306
|
+
for r in b_un:
|
|
307
|
+
print(" 段%-4d 相似度 %.2f(次高 %.2f) 「%s」" %
|
|
308
|
+
(r["i"], r["ratio"], r.get("second", 0), r["text"]))
|
|
309
|
+
|
|
310
|
+
total = len(f_un) + len(b_un)
|
|
311
|
+
print("-" * 78)
|
|
312
|
+
if total == 0:
|
|
313
|
+
print("✓ 无待判定项:本稿相对源稿只剩排版差异、图注编号与结尾附加句")
|
|
314
|
+
return 0
|
|
315
|
+
print("✗ %d 项待人工判定(正向新增 %d · 反向遗漏 %d)" % (total, len(f_un), len(b_un)))
|
|
316
|
+
print(" 判定口径:每一项要么归入「已声明的事实修正 / 已声明的增补」,")
|
|
317
|
+
print(" 要么就是不该出现的改动 —— 见技能 SKILL.md「改稿底线」。")
|
|
318
|
+
print(" 源稿里后加的块(勘误等)用 --skip '^>\\s*\\[!' 排除。")
|
|
319
|
+
return 1
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def main():
|
|
323
|
+
ap = argparse.ArgumentParser(
|
|
324
|
+
description="双向逐段溯源审计:查改稿有没有悄悄扩写或漏掉")
|
|
325
|
+
ap.add_argument("--draft", required=True, help="派生稿(如 公众号稿.md)")
|
|
326
|
+
ap.add_argument("--source", required=True, help="源稿(如 源稿-X长文.md)")
|
|
327
|
+
ap.add_argument("--threshold", type=float, default=0.72,
|
|
328
|
+
help="相似度阈值,低于它即待判定(默认 0.72)")
|
|
329
|
+
ap.add_argument("--skip", action="append", default=[],
|
|
330
|
+
help="正则:命中的段直接排除(可重复,两侧都生效)")
|
|
331
|
+
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
|
332
|
+
ap.add_argument("--verbose", action="store_true", help="打印逐段明细")
|
|
333
|
+
args = ap.parse_args()
|
|
334
|
+
|
|
335
|
+
for p in (args.draft, args.source):
|
|
336
|
+
try:
|
|
337
|
+
open(p, encoding="utf-8").close()
|
|
338
|
+
except OSError as e:
|
|
339
|
+
print("✗ 读不到文件:%s(%s)" % (p, e), file=sys.stderr)
|
|
340
|
+
return 2
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
skips = [re.compile(rx) for rx in args.skip]
|
|
344
|
+
except re.error as e:
|
|
345
|
+
print("✗ --skip 正则非法:%s" % e, file=sys.stderr)
|
|
346
|
+
return 2
|
|
347
|
+
|
|
348
|
+
res = audit(args.draft, args.source, args.threshold, skips)
|
|
349
|
+
|
|
350
|
+
if args.verbose:
|
|
351
|
+
print("逐段明细:")
|
|
352
|
+
for tag, rows in (("正", res["forward"]), ("反", res["backward"])):
|
|
353
|
+
for r in rows:
|
|
354
|
+
print(" %s 段%-4d %-11s %.2f %s" %
|
|
355
|
+
(tag, r["i"], r["kind"], r["ratio"], r["text"]))
|
|
356
|
+
print("-" * 78)
|
|
357
|
+
|
|
358
|
+
if args.json:
|
|
359
|
+
print(json.dumps(res, ensure_ascii=False, indent=2))
|
|
360
|
+
return 0 if not any(x["kind"] == "unexplained"
|
|
361
|
+
for x in res["forward"] + res["backward"]) else 1
|
|
362
|
+
return report(res)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if __name__ == "__main__":
|
|
366
|
+
sys.exit(main())
|
|
@@ -1,464 +1,101 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
scan_ai_flavor.py —
|
|
4
|
-
|
|
5
|
-
用法:
|
|
6
|
-
python3 scan_ai_flavor.py <file> # 输出 Markdown 报告
|
|
7
|
-
python3 scan_ai_flavor.py --json <file> # 输出 JSON
|
|
8
|
-
python3 scan_ai_flavor.py -o report.md <file>
|
|
9
|
-
cat article.md | python3 scan_ai_flavor.py - # 管道
|
|
10
|
-
|
|
11
|
-
设计目标:纯 stdlib,无第三方依赖,能在普通 Mac / Linux 上直接跑。
|
|
12
|
-
"""
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import json
|
|
17
|
-
import re
|
|
18
|
-
import sys
|
|
19
|
-
from collections import Counter
|
|
20
|
-
from dataclasses import dataclass, field, asdict
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
from typing import Iterable
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""scan_ai_flavor.py — 转发器:调用 qu-aiwei-zh 的 canonical 检测脚本。
|
|
23
4
|
|
|
5
|
+
**本文件不含检测实现。** 原因见下。
|
|
24
6
|
|
|
25
|
-
|
|
26
|
-
# 模式定义:每条 (类别代码, 类别名, 正则, 单次扣分, 严重度, 开头末尾加权, 备注)
|
|
27
|
-
# --------------------------------------------------------------------------- #
|
|
7
|
+
## 为什么是转发器而不是自带副本
|
|
28
8
|
|
|
9
|
+
topmind-wechat 曾自带一份 463 行的实现副本,它是 `qu-aiwei-zh` 在加入「E 类 · 作者姿态层」
|
|
10
|
+
**之前**的旧快照 —— 不输出姿态参考分,也识别不了姿态类 AI 痕迹。照 SKILL.md 跑它,会拿到一个
|
|
11
|
+
**没有姿态维度的「100 分」**:看着干净,其实少了一整个维度。
|
|
29
12
|
|
|
30
|
-
|
|
31
|
-
class Pattern:
|
|
32
|
-
code: str # e.g. "A1"
|
|
33
|
-
name: str # e.g. "开头套话"
|
|
34
|
-
regex: str # 正则字符串
|
|
35
|
-
penalty: int # 单次扣分(绝对值)
|
|
36
|
-
severity: str # high / mid / low
|
|
37
|
-
weighted: str = "" # "head" | "tail" | ""(加权区段)
|
|
38
|
-
note: str = ""
|
|
13
|
+
两版实测差异:旧副本 0 处「姿态」相关代码,canonical 20 处。
|
|
39
14
|
|
|
40
|
-
|
|
41
|
-
return re.compile(self.regex)
|
|
15
|
+
副本必然再次漂移(一边改了另一边不知道),所以这里只保留入口,实现归 canonical 一处。
|
|
42
16
|
|
|
17
|
+
## canonical 查找顺序(命中即用)
|
|
43
18
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
Pattern("A1", "开头套话", r"毋庸置疑[,,]|不可否认[,,]", 7, "high", "head"),
|
|
50
|
-
Pattern("A1", "开头套话", r"近年来[^,。]{0,15}日益", 6, "high", "head"),
|
|
51
|
-
Pattern("A1", "开头套话", r"伴随着[^,。]{2,30}的(快速|迅猛)", 8, "high", "head"),
|
|
52
|
-
Pattern("A1", "开头套话", r"在[^,。]{2,20}的背景下", 5, "mid", "head"),
|
|
53
|
-
Pattern("A1", "开头套话", r"一直以来[^,。]{2,20}都", 4, "low", "head"),
|
|
54
|
-
Pattern("A10", "模板口水", r"大家好[,,]", 6, "high", "head"),
|
|
55
|
-
Pattern("A10", "模板口水", r"本文将从[^,。]{0,30}个方面", 7, "high", "head"),
|
|
56
|
-
Pattern("A10", "模板口水", r"希望本文能够", 5, "mid", "head"),
|
|
57
|
-
Pattern("A10", "模板口水", r"如(有不当|有疏漏|有错误)[,,]", 4, "low", "head"),
|
|
58
|
-
]
|
|
59
|
-
|
|
60
|
-
# 全文模式
|
|
61
|
-
BODY_PATTERNS: list[Pattern] = [
|
|
62
|
-
# A2 转折连接
|
|
63
|
-
Pattern("A2", "转折连接", r"此外[,,]", 2, "low"),
|
|
64
|
-
Pattern("A2", "转折连接", r"与此同时[,,]", 2, "low"),
|
|
65
|
-
Pattern("A2", "转折连接", r"不仅如此[,,]", 2, "low"),
|
|
66
|
-
Pattern("A2", "转折连接", r"综上所述[,,]", 4, "mid"),
|
|
67
|
-
Pattern("A2", "转折连接", r"总而言之[,,]", 4, "mid"),
|
|
68
|
-
Pattern("A2", "转折连接", r"一方面[^,。]{1,15}[,,另一方面]", 3, "low"),
|
|
69
|
-
Pattern("A2", "转折连接", r"既[^,。]{1,12}又[^,。]{1,12}还", 3, "low"),
|
|
70
|
-
Pattern("A2", "转折连接", r"无可厚非", 4, "mid"),
|
|
71
|
-
Pattern("A2", "转折连接", r"瑕不掩瑜", 4, "mid"),
|
|
19
|
+
1. `$QU_AIWEI_SCAN` 显式指定(测试与非标准安装用)
|
|
20
|
+
2. `~/.workbuddy/skills/qu-aiwei-zh/scripts/scan_ai_flavor.py`
|
|
21
|
+
3. `~/.agents/skills/qu-aiwei-zh/scripts/scan_ai_flavor.py`
|
|
22
|
+
4. `~/.claude/skills/qu-aiwei-zh/scripts/scan_ai_flavor.py`
|
|
23
|
+
5. `~/.codex/skills/qu-aiwei-zh/scripts/scan_ai_flavor.py`
|
|
72
24
|
|
|
73
|
-
|
|
74
|
-
Pattern("A3", "官腔形容词", r"至关重要", 5, "high"),
|
|
75
|
-
Pattern("A3", "官腔形容词", r"举足轻重", 5, "high"),
|
|
76
|
-
Pattern("A3", "官腔形容词", r"不可或缺", 4, "mid"),
|
|
77
|
-
Pattern("A3", "官腔形容词", r"深入(探讨|分析|研究)", 5, "high"),
|
|
78
|
-
Pattern("A3", "官腔形容词", r"高度重视", 5, "high"),
|
|
79
|
-
Pattern("A3", "官腔形容词", r"深入推进", 5, "high"),
|
|
80
|
-
Pattern("A3", "官腔形容词", r"扎实开展", 5, "high"),
|
|
81
|
-
Pattern("A3", "官腔形容词", r"积极探索", 4, "mid"),
|
|
82
|
-
Pattern("A3", "官腔形容词", r"有效落实", 4, "mid"),
|
|
83
|
-
Pattern("A3", "官腔形容词", r"持续优化", 4, "mid"),
|
|
84
|
-
Pattern("A3", "官腔形容词", r"凝聚共识", 5, "high"),
|
|
85
|
-
Pattern("A3", "官腔形容词", r"汇聚合力", 5, "high"),
|
|
86
|
-
Pattern("A3", "官腔形容词", r"协同推进", 4, "mid"),
|
|
87
|
-
Pattern("A3", "官腔形容词", r"全方位(提升|推进|优化)", 4, "mid"),
|
|
88
|
-
Pattern("A3", "官腔形容词", r"多角度(分析|审视)", 4, "mid"),
|
|
89
|
-
Pattern("A3", "官腔形容词", r"立体化", 3, "low"),
|
|
90
|
-
# 注:数字化/智能化 是领域术语,不作为 AI 味标志,跳过
|
|
25
|
+
## 行为
|
|
91
26
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
27
|
+
- 参数原样透传(位置参数 `<file>` / `--json` / `-o` / `-` 管道都可用),stdin / stdout / stderr 继承
|
|
28
|
+
- 退出码取 canonical 的退出码
|
|
29
|
+
- `--which` 只打印解析到的 canonical 路径并退出 0(转发器自有开关,canonical 无此参数)
|
|
30
|
+
- **找不到 canonical 时报错退出(exit 3),不静默降级** —— 宁可这一步跑不了,
|
|
31
|
+
也不要给出一个没有姿态分的分数
|
|
95
32
|
|
|
96
|
-
|
|
97
|
-
Pattern("A6", "营销黑话", r"赋能", 5, "high"),
|
|
98
|
-
Pattern("A6", "营销黑话", r"加持", 5, "high"),
|
|
99
|
-
Pattern("A6", "营销黑话", r"引爆", 5, "high"),
|
|
100
|
-
Pattern("A6", "营销黑话", r"破圈", 5, "high"),
|
|
101
|
-
Pattern("A6", "营销黑话", r"一站式", 4, "mid"),
|
|
102
|
-
Pattern("A6", "营销黑话", r"全链路", 4, "mid"),
|
|
103
|
-
Pattern("A6", "营销黑话", r"闭环", 4, "mid"),
|
|
104
|
-
Pattern("A6", "营销黑话", r"抓手", 5, "high"),
|
|
105
|
-
Pattern("A6", "营销黑话", r"底层逻辑", 5, "high"),
|
|
106
|
-
Pattern("A6", "营销黑话", r"顶层设计", 5, "high"),
|
|
107
|
-
Pattern("A6", "营销黑话", r"(打造|构建|重塑)[^,。]{2,15}(方案|生态|体系|平台)", 3, "low"),
|
|
108
|
-
Pattern("A6", "营销黑话", r"拉通|对齐|复盘", 3, "low"),
|
|
109
|
-
# 工程圈行话:用户明确点过——「踩坑」这类词读起来像圈内口令,不像人在说话
|
|
110
|
-
Pattern("A6", "工程圈行话", r"踩坑|避坑|干货|踩出来的坑|踩过的坑", 3, "low"),
|
|
33
|
+
## 用法
|
|
111
34
|
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
Pattern("A7", "模糊归因", r"据相关(研究|调查|数据)表明", 5, "high"),
|
|
116
|
-
Pattern("A7", "模糊归因", r"大量数据表明", 5, "high"),
|
|
117
|
-
Pattern("A7", "模糊归因", r"有学者认为", 4, "mid"),
|
|
118
|
-
Pattern("A7", "模糊归因", r"专家(普遍|一致)认为", 4, "mid"),
|
|
119
|
-
Pattern("A7", "模糊归因", r"大家普遍认为", 3, "low"),
|
|
35
|
+
python3 scripts/scan_ai_flavor.py <包>/公众号稿.md # 目标 ≥85
|
|
36
|
+
python3 scripts/scan_ai_flavor.py --which
|
|
37
|
+
"""
|
|
120
38
|
|
|
121
|
-
|
|
122
|
-
Pattern("A8", "伪深度", r"(是|为)[^,。]{2,15}的(体现|证明|象征)", 5, "high"),
|
|
123
|
-
Pattern("A8", "伪深度", r"彰显了[^,。]{2,20}(意义|价值|精神)", 5, "high"),
|
|
124
|
-
Pattern("A8", "伪深度", r"凸显了[^,。]{2,20}(意义|价值)", 5, "high"),
|
|
125
|
-
Pattern("A8", "伪深度", r"反映了更深(层次)的", 4, "mid"),
|
|
39
|
+
from __future__ import annotations
|
|
126
40
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
Pattern("A9", "假大空成语", r"源远流长", 4, "mid"),
|
|
130
|
-
Pattern("A9", "假大空成语", r"历久弥新", 4, "mid"),
|
|
131
|
-
Pattern("A9", "假大空成语", r"与时俱进", 3, "low"),
|
|
132
|
-
Pattern("A9", "假大空成语", r"开拓创新", 3, "low"),
|
|
133
|
-
Pattern("A9", "假大空成语", r"锐意进取", 3, "low"),
|
|
134
|
-
Pattern("A9", "假大空成语", r"举世(瞩目|无双)", 4, "mid"),
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
135
43
|
|
|
136
|
-
|
|
137
|
-
Pattern("A10", "模板口水", r"感谢(您的)?(观看|阅读|支持)", 5, "high"),
|
|
138
|
-
Pattern("A10", "模板口水", r"以上就是[^,。]{2,30}(全部)?内容", 5, "high"),
|
|
139
|
-
Pattern("A10", "模板口水", r"欢迎(指正|交流|留言)", 4, "mid"),
|
|
140
|
-
]
|
|
44
|
+
REL = os.path.join("qu-aiwei-zh", "scripts", "scan_ai_flavor.py")
|
|
141
45
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
Pattern("A4", "结尾升华", r"砥砺前行|扬帆远航", 5, "high", "tail"),
|
|
148
|
-
Pattern("A4", "结尾升华", r"在[^,。]{2,20}的道路上(不断)?前行", 6, "high", "tail"),
|
|
149
|
-
Pattern("A4", "结尾升华", r"为[^,。]{2,15}贡献(自己)?的?力量", 6, "high", "tail"),
|
|
150
|
-
Pattern("A4", "结尾升华", r"谱写[^,。]{2,15}新篇章", 6, "high", "tail"),
|
|
151
|
-
Pattern("A4", "结尾升华", r"展望未来", 4, "mid", "tail"),
|
|
46
|
+
CANDIDATES = [
|
|
47
|
+
os.path.join("~", ".workbuddy", "skills", REL),
|
|
48
|
+
os.path.join("~", ".agents", "skills", REL),
|
|
49
|
+
os.path.join("~", ".claude", "skills", REL),
|
|
50
|
+
os.path.join("~", ".codex", "skills", REL),
|
|
152
51
|
]
|
|
153
52
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
# --------------------------------------------------------------------------- #
|
|
158
|
-
# 数据结构
|
|
159
|
-
# --------------------------------------------------------------------------- #
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
@dataclass
|
|
163
|
-
class Hit:
|
|
164
|
-
code: str
|
|
165
|
-
name: str
|
|
166
|
-
pattern: str
|
|
167
|
-
text: str
|
|
168
|
-
line: int
|
|
169
|
-
column: int
|
|
170
|
-
severity: str
|
|
171
|
-
penalty: int
|
|
172
|
-
raw_penalty: int # 加权前
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
@dataclass
|
|
176
|
-
class Report:
|
|
177
|
-
score: int
|
|
178
|
-
level: str
|
|
179
|
-
level_emoji: str
|
|
180
|
-
hits: list[Hit] = field(default_factory=list)
|
|
181
|
-
lexical_deduction: int = 0
|
|
182
|
-
structural_deduction: int = 0
|
|
183
|
-
stats: dict = field(default_factory=dict)
|
|
184
|
-
suggestions: list[str] = field(default_factory=list)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# --------------------------------------------------------------------------- #
|
|
188
|
-
# 扫描逻辑
|
|
189
|
-
# --------------------------------------------------------------------------- #
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def scan_text(text: str) -> Report:
|
|
193
|
-
lines = text.splitlines()
|
|
194
|
-
total_chars = len(text)
|
|
195
|
-
total_lines = len(lines)
|
|
196
|
-
head_chars = "".join(lines[: max(1, total_lines // 5)]) # 前 20%
|
|
197
|
-
tail_chars = "".join(lines[-max(1, total_lines // 5):]) # 后 20%
|
|
198
|
-
|
|
199
|
-
hits: list[Hit] = []
|
|
200
|
-
seen_in_text: dict[tuple[str, str], int] = {} # (pattern, exact_text) → 第 N 次出现
|
|
201
|
-
|
|
202
|
-
for p in ALL_PATTERNS:
|
|
203
|
-
regex = p.compiled()
|
|
204
|
-
for m in regex.finditer(text):
|
|
205
|
-
# 计算行号/列号
|
|
206
|
-
start = m.start()
|
|
207
|
-
line_no = text.count("\n", 0, start) + 1
|
|
208
|
-
col_no = start - (text.rfind("\n", 0, start) + 1) + 1
|
|
209
|
-
|
|
210
|
-
# 计算加权:位置决定
|
|
211
|
-
region = "body"
|
|
212
|
-
if p.weighted == "head" and start < len(head_chars):
|
|
213
|
-
region = "head"
|
|
214
|
-
elif p.weighted == "tail" and start >= len(text) - len(tail_chars):
|
|
215
|
-
region = "tail"
|
|
216
|
-
|
|
217
|
-
raw = p.penalty
|
|
218
|
-
if region != "body":
|
|
219
|
-
raw = int(raw * 1.5)
|
|
220
|
-
|
|
221
|
-
# 重复衰减:同一模式第 N 次出现
|
|
222
|
-
key = (p.regex, m.group(0))
|
|
223
|
-
n = seen_in_text.get(key, 0) + 1
|
|
224
|
-
seen_in_text[key] = n
|
|
225
|
-
multiplier = min(n, 3) # 最多 3 次
|
|
53
|
+
MISSING = """\
|
|
54
|
+
✗ 找不到 qu-aiwei-zh 的 canonical 检测脚本,无法执行「文字关」。
|
|
226
55
|
|
|
227
|
-
|
|
56
|
+
本目录不再自带实现 —— 曾经那份是 E 类姿态检测之前的旧快照,会漏掉姿态维度。
|
|
57
|
+
已查找:
|
|
58
|
+
{paths}
|
|
228
59
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
name=p.name,
|
|
233
|
-
pattern=p.regex,
|
|
234
|
-
text=m.group(0),
|
|
235
|
-
line=line_no,
|
|
236
|
-
column=col_no,
|
|
237
|
-
severity=p.severity,
|
|
238
|
-
penalty=final,
|
|
239
|
-
raw_penalty=raw,
|
|
240
|
-
)
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
# 结构扣分
|
|
244
|
-
structural = 0
|
|
245
|
-
# 重复的段首加粗标签:**结论**:/ **踩坑提醒**:/ **注意**: …
|
|
246
|
-
# 这是模板化写作最典型的痕迹,词面词表抓不到,只能按"同一标签重复出现"判定。
|
|
247
|
-
# 判据取 ≥3 次:用两次属于正常强调,用三次以上就是在套模板。
|
|
248
|
-
lead_labels = re.findall(r"^\s*\*\*([^*\n]{1,10})\*\*\s*[::]", text, re.M)
|
|
249
|
-
repeated_labels: list[str] = []
|
|
250
|
-
if lead_labels:
|
|
251
|
-
counts = Counter(lead_labels)
|
|
252
|
-
repeated_labels = [k for k, v in counts.items() if v >= 3]
|
|
253
|
-
if total_lines >= 5:
|
|
254
|
-
# 破折号过多
|
|
255
|
-
dash_count = text.count("——")
|
|
256
|
-
if dash_count >= max(3, total_chars // 200):
|
|
257
|
-
structural -= 5
|
|
258
|
-
# 感叹号过多
|
|
259
|
-
if text.count("!!!") >= 1 or text.count("!!") >= 3:
|
|
260
|
-
structural -= 3
|
|
261
|
-
# 缺乏具体细节(无任何数字)
|
|
262
|
-
if not re.search(r"\d", text):
|
|
263
|
-
structural -= 12
|
|
264
|
-
# 缺乏个性化声音(无第一人称)
|
|
265
|
-
if not re.search(r"[我我们]", text):
|
|
266
|
-
structural -= 8
|
|
267
|
-
# 标题 emoji 滥用
|
|
268
|
-
emoji_count = len(re.findall(r"[\U0001F300-\U0001FAFF\U00002600-\U000027BF]", text))
|
|
269
|
-
if emoji_count >= 3:
|
|
270
|
-
structural -= min(9, (emoji_count - 2) * 3)
|
|
271
|
-
# 段首加粗标签模板化
|
|
272
|
-
if repeated_labels:
|
|
273
|
-
structural -= min(12, sum(lead_labels.count(k) for k in repeated_labels) * 2)
|
|
274
|
-
|
|
275
|
-
# 词面扣分汇总
|
|
276
|
-
# 注意:structural 累加时是负数(structural -= n),必须取绝对值再相加。
|
|
277
|
-
# 若直接相加,lexical + structural 会变成"加分",结构扣分整条维度静默失效。
|
|
278
|
-
lexical = sum(h.penalty for h in hits)
|
|
279
|
-
structural = abs(structural)
|
|
280
|
-
total_deduction = lexical + structural
|
|
281
|
-
score = max(0, min(100, 100 - total_deduction))
|
|
282
|
-
|
|
283
|
-
level, level_emoji = grade(score)
|
|
284
|
-
|
|
285
|
-
# 生成建议(Top 3)
|
|
286
|
-
suggestions = build_suggestions(hits, score)
|
|
287
|
-
if repeated_labels:
|
|
288
|
-
tips = "、".join(f"**{k}**:×{lead_labels.count(k)}" for k in repeated_labels)
|
|
289
|
-
suggestions.insert(0, f"段首加粗标签模板化 — {tips},同一标签重复 3 次以上就是在套模板,改成直陈句")
|
|
290
|
-
|
|
291
|
-
return Report(
|
|
292
|
-
score=score,
|
|
293
|
-
level=level,
|
|
294
|
-
level_emoji=level_emoji,
|
|
295
|
-
hits=hits,
|
|
296
|
-
lexical_deduction=lexical,
|
|
297
|
-
structural_deduction=structural,
|
|
298
|
-
stats={
|
|
299
|
-
"total_chars": total_chars,
|
|
300
|
-
"total_lines": total_lines,
|
|
301
|
-
"ai_pattern_count": len(hits),
|
|
302
|
-
"repeated_lead_labels": repeated_labels,
|
|
303
|
-
},
|
|
304
|
-
suggestions=suggestions,
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def grade(score: int) -> tuple[str, str]:
|
|
309
|
-
if score >= 85:
|
|
310
|
-
return "人话", "🟢"
|
|
311
|
-
if score >= 70:
|
|
312
|
-
return "微 AI 味", "🟡"
|
|
313
|
-
if score >= 50:
|
|
314
|
-
return "中度 AI 味", "🟠"
|
|
315
|
-
if score >= 30:
|
|
316
|
-
return "重度 AI 味", "🔴"
|
|
317
|
-
return "完全 AI 体", "⚫"
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
def build_suggestions(hits: list[Hit], score: int) -> list[str]:
|
|
321
|
-
"""按类别聚类,给出 3 条优先建议。"""
|
|
322
|
-
by_code: dict[str, list[Hit]] = {}
|
|
323
|
-
for h in hits:
|
|
324
|
-
by_code.setdefault(h.code, []).append(h)
|
|
325
|
-
|
|
326
|
-
# 优先级:A4>A3>A6>A7>A1>A8>A10>A5>A9
|
|
327
|
-
priority = ["A4", "A3", "A6", "A7", "A1", "A8", "A10", "A5", "A9"]
|
|
328
|
-
tips_map = {
|
|
329
|
-
"A1": "开头套话 — 删掉'在当今社会'等模板开头,直接说事实",
|
|
330
|
-
"A2": "转折词 — 用句号断开,靠上下文自然过渡",
|
|
331
|
-
"A3": "官腔形容词 — 换成大白话或具体数字",
|
|
332
|
-
"A4": "结尾升华 — 删掉'让我们共同'等空话,给具体计划",
|
|
333
|
-
"A5": "排比三段式 — 砍到两项或四项,别硬凑三段",
|
|
334
|
-
"A6": "营销黑话 — 改为具体动词,如'做了一个工具'",
|
|
335
|
-
"A7": "模糊归因 — 给具体名字/出处/数字",
|
|
336
|
-
"A8": "伪深度 — 删除'是 XX 的体现/证明'句式",
|
|
337
|
-
"A9": "假大空成语 — 用白话翻译一遍",
|
|
338
|
-
"A10": "模板口水 — 删掉口水句,直接进入正文",
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
suggestions = []
|
|
342
|
-
for code in priority:
|
|
343
|
-
if code in by_code:
|
|
344
|
-
n = len(by_code[code])
|
|
345
|
-
suggestions.append(f"{tips_map[code]}(命中 {n} 处)")
|
|
346
|
-
if len(suggestions) >= 3:
|
|
347
|
-
break
|
|
348
|
-
|
|
349
|
-
if not suggestions:
|
|
350
|
-
suggestions.append("未发现明显 AI 模式,但建议人工通读检查结构/个性化声音")
|
|
351
|
-
|
|
352
|
-
return suggestions
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
# --------------------------------------------------------------------------- #
|
|
356
|
-
# 输出格式
|
|
357
|
-
# --------------------------------------------------------------------------- #
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def render_markdown(report: Report, source: str) -> str:
|
|
361
|
-
md: list[str] = []
|
|
362
|
-
md.append(f"## AI 味检测报告\n")
|
|
363
|
-
md.append(f"**文件:** `{source}` ")
|
|
364
|
-
md.append(f"**总分:** **{report.score}** / 100 {report.level_emoji} {report.level}\n")
|
|
365
|
-
|
|
366
|
-
md.append("### 分项评分")
|
|
367
|
-
md.append(f"- 词面命中扣分:**-{report.lexical_deduction}**")
|
|
368
|
-
md.append(f"- 结构问题扣分:**{report.structural_deduction}**")
|
|
369
|
-
md.append(f"- 总计:**-{report.lexical_deduction + abs(report.structural_deduction)}** → {report.score} 分\n")
|
|
370
|
-
|
|
371
|
-
md.append("### 基础统计")
|
|
372
|
-
md.append(f"- 字符数:{report.stats.get('total_chars', 0)}")
|
|
373
|
-
md.append(f"- 行数:{report.stats.get('total_lines', 0)}")
|
|
374
|
-
md.append(f"- AI 模式命中数:**{report.stats.get('ai_pattern_count', 0)}**")
|
|
375
|
-
reps = report.stats.get("repeated_lead_labels") or []
|
|
376
|
-
if reps:
|
|
377
|
-
md.append(f"- 重复段首加粗标签:**{'、'.join(reps)}**")
|
|
378
|
-
md.append("")
|
|
379
|
-
|
|
380
|
-
if report.hits:
|
|
381
|
-
md.append("### 命中位置(按严重度排序)")
|
|
382
|
-
md.append("| # | 类别 | 命中内容 | 位置 | 严重度 | 扣分 |")
|
|
383
|
-
md.append("|---|---|---|---|---|---|")
|
|
384
|
-
sorted_hits = sorted(report.hits, key=lambda h: -h.penalty)
|
|
385
|
-
for i, h in enumerate(sorted_hits[:30], 1):
|
|
386
|
-
preview = h.text if len(h.text) <= 25 else h.text[:25] + "…"
|
|
387
|
-
md.append(f"| {i} | {h.code} {h.name} | `{preview}` | 第 {h.line} 行 | {h.severity} | -{h.penalty} |")
|
|
388
|
-
if len(report.hits) > 30:
|
|
389
|
-
md.append(f"\n*(还有 {len(report.hits) - 30} 处命中未显示)*\n")
|
|
390
|
-
else:
|
|
391
|
-
md.append("### 命中位置")
|
|
392
|
-
md.append("无命中 🎉\n")
|
|
393
|
-
|
|
394
|
-
md.append("### Top 3 优先改写建议")
|
|
395
|
-
for s in report.suggestions:
|
|
396
|
-
md.append(f"1. {s}")
|
|
397
|
-
md.append("")
|
|
398
|
-
|
|
399
|
-
return "\n".join(md)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def render_json(report: Report) -> str:
|
|
403
|
-
return json.dumps(
|
|
404
|
-
{
|
|
405
|
-
"score": report.score,
|
|
406
|
-
"level": report.level,
|
|
407
|
-
"level_emoji": report.level_emoji,
|
|
408
|
-
"deductions": {
|
|
409
|
-
"lexical": report.lexical_deduction,
|
|
410
|
-
"structural": report.structural_deduction,
|
|
411
|
-
},
|
|
412
|
-
"stats": report.stats,
|
|
413
|
-
"hits": [asdict(h) for h in report.hits],
|
|
414
|
-
"suggestions": report.suggestions,
|
|
415
|
-
},
|
|
416
|
-
ensure_ascii=False,
|
|
417
|
-
indent=2,
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
# --------------------------------------------------------------------------- #
|
|
422
|
-
# CLI
|
|
423
|
-
# --------------------------------------------------------------------------- #
|
|
60
|
+
解决:安装 qu-aiwei-zh 技能,或用环境变量显式指定:
|
|
61
|
+
export QU_AIWEI_SCAN=/path/to/qu-aiwei-zh/scripts/scan_ai_flavor.py
|
|
62
|
+
"""
|
|
424
63
|
|
|
425
64
|
|
|
426
|
-
def
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
65
|
+
def resolve():
|
|
66
|
+
"""返回 canonical 脚本的绝对路径;找不到返回 None。"""
|
|
67
|
+
env = os.environ.get("QU_AIWEI_SCAN")
|
|
68
|
+
if env:
|
|
69
|
+
p = os.path.abspath(os.path.expanduser(env))
|
|
70
|
+
return p if os.path.isfile(p) else None
|
|
71
|
+
for c in CANDIDATES:
|
|
72
|
+
p = os.path.abspath(os.path.expanduser(c))
|
|
73
|
+
if os.path.isfile(p):
|
|
74
|
+
return p
|
|
75
|
+
return None
|
|
434
76
|
|
|
435
|
-
# 读取输入
|
|
436
|
-
if args.input == "-":
|
|
437
|
-
text = sys.stdin.read()
|
|
438
|
-
source = "<stdin>"
|
|
439
|
-
else:
|
|
440
|
-
p = Path(args.input)
|
|
441
|
-
if not p.exists():
|
|
442
|
-
print(f"❌ 文件不存在: {args.input}", file=sys.stderr)
|
|
443
|
-
return 1
|
|
444
|
-
text = p.read_text(encoding="utf-8")
|
|
445
|
-
source = str(p)
|
|
446
77
|
|
|
447
|
-
|
|
78
|
+
def main():
|
|
79
|
+
args = sys.argv[1:]
|
|
448
80
|
|
|
449
|
-
if args
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
81
|
+
if args and args[0] == "--which":
|
|
82
|
+
target = resolve()
|
|
83
|
+
if target:
|
|
84
|
+
print(target)
|
|
85
|
+
return 0
|
|
86
|
+
print("✗ 未解析到 canonical 脚本;用 QU_AIWEI_SCAN 指定或安装 qu-aiwei-zh",
|
|
87
|
+
file=sys.stderr)
|
|
88
|
+
return 3
|
|
453
89
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
90
|
+
target = resolve()
|
|
91
|
+
if not target:
|
|
92
|
+
shown = [os.path.abspath(os.path.expanduser(c)) for c in CANDIDATES]
|
|
93
|
+
sys.stderr.write(MISSING.format(paths="\n".join(" " + p for p in shown)))
|
|
94
|
+
return 3
|
|
459
95
|
|
|
460
|
-
|
|
96
|
+
os.execv(sys.executable, [sys.executable, target] + args)
|
|
97
|
+
return 0 # 不会到达
|
|
461
98
|
|
|
462
99
|
|
|
463
100
|
if __name__ == "__main__":
|
|
464
|
-
sys.exit(main(
|
|
101
|
+
sys.exit(main())
|
package/topmind-weread/SKILL.md
CHANGED
package/topmind-write/SKILL.md
CHANGED