@topmindspace/topmind-skills 4.15.0 → 4.15.2
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/CHANGELOG.md +33 -0
- package/INSTALL.md +10 -2
- package/README.en.md +18 -14
- package/README.md +19 -15
- package/README.zh-CN.md +19 -15
- package/bin/install-skills.mjs +48 -8
- package/evals/evals.json +108 -3
- package/install-targets/claude-code.json +1 -2
- package/install-targets/codex.json +1 -2
- package/install-targets/hermes.json +1 -2
- package/install-targets/mimocode.json +1 -2
- package/install-targets/opencode.json +1 -2
- package/install-targets/universal.json +1 -2
- package/package.json +3 -3
- package/shared/host-loading.md +3 -3
- package/shared/trigger-disambiguation.md +21 -2
- package/skills.md +1 -1
- package/topmind/SKILL.md +20 -23
- package/topmind/references/multi-intent.md +2 -0
- package/topmind-capture/SKILL.md +11 -34
- package/topmind-ledger/SKILL.md +11 -18
- package/topmind-loop/SKILL.md +11 -23
- package/topmind-maintain/SKILL.md +11 -24
- package/topmind-memory/SKILL.md +11 -21
- package/topmind-organize/SKILL.md +15 -36
- package/topmind-pack.json +49 -29
- package/topmind-weread/SKILL.md +11 -18
- package/topmind-write/SKILL.md +15 -32
- package/topmind-x/SKILL.md +13 -19
- package/topmind-wechat/DESIGN.md +0 -50
- package/topmind-wechat/SKILL.md +0 -229
- package/topmind-wechat/assets/themes/amber-review.json +0 -35
- package/topmind-wechat/assets/themes/graphite.json +0 -35
- package/topmind-wechat/assets/themes/minimal-ink.json +0 -35
- package/topmind-wechat/assets/themes/newsprint.json +0 -35
- package/topmind-wechat/assets/themes/tech-blue.json +0 -35
- package/topmind-wechat/references/element-spec.md +0 -162
- package/topmind-wechat/references/known-pits.md +0 -86
- package/topmind-wechat/references/theme-map.md +0 -139
- package/topmind-wechat/references/typography-rules.md +0 -62
- package/topmind-wechat/references/wechat-constraints.md +0 -106
- package/topmind-wechat/references/workflow.md +0 -113
- package/topmind-wechat/references/writing-quality.md +0 -137
- package/topmind-wechat/scripts/audit-provenance.py +0 -366
- package/topmind-wechat/scripts/lint-wechat.py +0 -446
- package/topmind-wechat/scripts/md2wechat.py +0 -1599
- package/topmind-wechat/scripts/new-article.py +0 -183
- package/topmind-wechat/scripts/push-to-topstream.py +0 -226
- package/topmind-wechat/scripts/scan_ai_flavor.py +0 -101
- package/topmind-wechat/scripts/sync-mapping.py +0 -220
- package/topmind-wechat/scripts/sync-status.py +0 -187
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
# 工作流 · 交付包 · 状态机
|
|
2
|
-
|
|
3
|
-
## 生命周期
|
|
4
|
-
|
|
5
|
-
```text
|
|
6
|
-
forward: 底稿 notes/*.md → 审校改写 → 质量三关 → 定稿 → 排版 → 发布
|
|
7
|
-
reverse: 选题包 → 调研/素材 → 多轮改稿 → 质量三关 → 定稿 → 排版 → 发布 →(可选)回推
|
|
8
|
-
站外拉取 (ext): 在线精选站/转载整合 → 创作/整合 → 质量三关 → 定稿 → 排版 → 发布 →(可选)回推
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
`direction` 写在 frontmatter:`forward | reverse`。**站外拉取也用 `reverse` + `target_file: pending`**(站外源不进 `notes/`,`forward` 会因 `source_file` 校验失败)。
|
|
12
|
-
|
|
13
|
-
## 建包
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
export TOPMIND_WORKSPACE=/path/to/ws
|
|
17
|
-
python3 scripts/new-article.py --slug <中文或英文短名> --title "中文标题" \
|
|
18
|
-
--direction reverse \
|
|
19
|
-
[--base <包根>] [--date YYYY-MM-DD] [--images a.png b.png] \
|
|
20
|
-
[--source-file notes/xxx.md] # forward
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
产出目录 `YYYY-MM-DD-<中文短名>/`,骨架 `公众号稿.md`。
|
|
24
|
-
|
|
25
|
-
### 站外拉取取源注意
|
|
26
|
-
|
|
27
|
-
- Next.js 站点正文常在 RSC 载荷里:优先探 `GET /api/notes/<id>` 读 `item.body`,别只 `curl` HTML。
|
|
28
|
-
- 图片可能在 `/api/uploads/<hash>`;下载时记录 hash → 本地名映射。
|
|
29
|
-
- 同图可能双 hash:`md5` 去重;截图带页面残留时按面板边框裁切。
|
|
30
|
-
- 与源差异、图片处理、口径边界写进包内 `README.md`;一手来源抓不到必须写明。
|
|
31
|
-
|
|
32
|
-
## 反向选题包(可选,长文建议)
|
|
33
|
-
|
|
34
|
-
```text
|
|
35
|
-
YYYY-MM-DD-选题策划/
|
|
36
|
-
├── 选题策划.md
|
|
37
|
-
├── T1-<题>-调研与框架.md
|
|
38
|
-
├── 素材总库.md
|
|
39
|
-
├── 图表与配图方案.md
|
|
40
|
-
└── 发布前待办.md
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
多轮调研**每轮回头找反证**。
|
|
44
|
-
|
|
45
|
-
## 状态机
|
|
46
|
-
|
|
47
|
-
| status | 目录名 | 含义 |
|
|
48
|
-
|--------|--------|------|
|
|
49
|
-
| 草稿 | `YYYY-MM-DD-名` | 写作中 |
|
|
50
|
-
| 定稿 | `…-released` | 可发 |
|
|
51
|
-
| 已发布 | `…-released` | 已上线(作者手动改) |
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
python3 scripts/sync-status.py # dry-run
|
|
55
|
-
python3 scripts/sync-status.py --apply # 刷字数 + 纠目录名
|
|
56
|
-
python3 scripts/sync-status.py --set 定稿 <包> --apply
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
## 字数口径(三处别混)
|
|
60
|
-
|
|
61
|
-
| 输出 | 口径 | 用途 |
|
|
62
|
-
|------|------|------|
|
|
63
|
-
| frontmatter `word_count` | 正文**全文**的 `[\u4e00-\u9fff]` 计数 —— **frontmatter 之后的一切都算,含图注、也含图片路径里的中文** | 映射真源 |
|
|
64
|
-
| lint「字数」 | 视觉长度(中 1 + 英数 ×0.6,URL 不计) | 阅读时长 / 段长 |
|
|
65
|
-
| md2wechat「正文字数」 | 渲染后字符 | 仅展示 |
|
|
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
|
-
|
|
69
|
-
## 定稿验收命令串
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
python3 scripts/scan_ai_flavor.py <包>/公众号稿.md # 总分 ≥85 且「作者姿态分」≥85
|
|
73
|
-
python3 scripts/lint-wechat.py --input <包>/公众号稿.md
|
|
74
|
-
python3 scripts/audit-provenance.py --draft <包>/公众号稿.md --source <包>/源稿-*.md
|
|
75
|
-
python3 scripts/sync-mapping.py --no-topstream
|
|
76
|
-
python3 scripts/sync-status.py
|
|
77
|
-
python3 scripts/md2wechat.py --input <包>/公众号稿.md --out-dir <包> \
|
|
78
|
-
--slug <slug> --asset-root <素材根> --embed-images
|
|
79
|
-
```
|
|
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
|
-
|
|
89
|
-
## 收尾
|
|
90
|
-
|
|
91
|
-
1. 更新专题 `topic.md` 总表(派生视图)
|
|
92
|
-
2. reverse 且要回推:**务必带 `--assets`**,否则 GitHub 上 `images/` 全裂:
|
|
93
|
-
|
|
94
|
-
```bash
|
|
95
|
-
python3 scripts/push-to-topstream.py <包> --target notes/xxx.md --assets # dry-run
|
|
96
|
-
python3 scripts/push-to-topstream.py <包> --target notes/xxx.md --assets --apply \
|
|
97
|
-
--asset-names "00-封面.jpg=01-cover.jpg,…" # 仓库约定 ASCII 图名
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
仓库图路径约定:`notes/<slug>.md` 引用 `../assets/images/<slug>/NN-name.png`。
|
|
101
|
-
脚本只降级转换 + 写 notes + 搬图;**不自动改** README 索引 / `docs/公众号映射.md` / frontmatter(防索引漂移)。手工完成后再把 `target_file` 改实际路径。
|
|
102
|
-
3. 提醒:外链只能进「阅读原文」;图已 base64 内嵌,占位符按清单补传
|
|
103
|
-
4. 需要进交付层时:`save-output` 拷贝终稿到 `88-交付/`(`YYYY-MM-DD-描述.ext`)
|
|
104
|
-
|
|
105
|
-
## Desktop
|
|
106
|
-
|
|
107
|
-
| 步骤 | Desktop「公众号创作」 |
|
|
108
|
-
|------|----------------------|
|
|
109
|
-
| 建包/列表 | 扫工作区包根 |
|
|
110
|
-
| 改稿 | 写闸回执 |
|
|
111
|
-
| 三关 | AI 味 + lint + 事实勾选 |
|
|
112
|
-
| 排版 | 主题预览 + 调脚本导出 |
|
|
113
|
-
| 状态 | 调 `sync-status` |
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
# 写作质量参考
|
|
2
|
-
|
|
3
|
-
定稿前三关的可执行清单,以及这几轮改稿里真实踩过的坑。
|
|
4
|
-
|
|
5
|
-
## 一、事实关
|
|
6
|
-
|
|
7
|
-
### 检查动作
|
|
8
|
-
|
|
9
|
-
1. 列出全文所有承重数字(会被引用的、支撑结论的),逐条回溯一手来源
|
|
10
|
-
2. 一手来源优先级:厂商定价页/官方博客/官方公告 > arXiv 原文 > 政府与监管机构网站 > 标准组织官网 > 一手媒体报道
|
|
11
|
-
3. 标注口径:厂商自报标「厂商口径」;社区转述标「据报道」;预测类写清「这是预测,不是已发生的事实」
|
|
12
|
-
4. 无一手来源的传闻**直接删**,不要为了完整留着
|
|
13
|
-
|
|
14
|
-
### 高频错误
|
|
15
|
-
|
|
16
|
-
- **跨版本混用**:同一指标的不同年度版本数字混着用(IBM 2025 版 97% vs 2026 版 92%)
|
|
17
|
-
- **单位错误**:「人均代码 4450→7839 行」实际是每月不是每年
|
|
18
|
-
- **口径换算**:「3MB → 64MB 提高二十多倍」——前后计量方式不同(压缩后 MB vs 未压缩 MiB),不能换算
|
|
19
|
-
- **州别/主体错位**:「佛蒙特约 600 家数据经纪商」实际是加州 500-600、佛蒙特 283-467
|
|
20
|
-
- **把预测写成事实**:Gartner「2027 年底前 40% 项目被取消」是 2025-06 发布的**预测**
|
|
21
|
-
|
|
22
|
-
### 核查员也会误判,必须自己复核
|
|
23
|
-
|
|
24
|
-
出现「查不到/有反证」时,先确认它搜对了地方:
|
|
25
|
-
|
|
26
|
-
- Mem0 的 issue #4573 被误判为不存在——核查员搜的是 `anthropics/claude-code`,实际在 `mem0ai/mem0`
|
|
27
|
-
- ChatGPT 项目记忆 2025-08 被误判——`project-only memory` 与 `Projects` 功能是两回事
|
|
28
|
-
- Claude Cowork 记忆合并 2026-08-25 被误判——Cowork 产品 2026-01 发布,但记忆合并是 8 月
|
|
29
|
-
|
|
30
|
-
**可硬核实的数字自己动手取**:GitHub star 用 `curl https://api.github.com/repos/{owner}/{repo}` 取 `stargazers_count`,比搜来的区间值准。
|
|
31
|
-
|
|
32
|
-
## 二、逻辑关
|
|
33
|
-
|
|
34
|
-
- **每个单边结论旁配反方证据**。只写一方,文章就只剩卖方叙事
|
|
35
|
-
- **结构前后一致**:结论前置的体例要贯穿全篇;短清单里的数字必须与正文同值
|
|
36
|
-
- **区分「预测」与「已发生」**、「厂商自报」与「第三方复测」、「相关」与「因果」
|
|
37
|
-
- **核心论断先找反证**:提出一个判断时,先搜「反对这个判断的证据」。曾因此把「harness 不重要」改写成更站得住的版本——找到了「只改 harness 不动模型就让准确率涨 13.7 个百分点」的硬反证
|
|
38
|
-
|
|
39
|
-
## 三、文字关(去 AI 味)
|
|
40
|
-
|
|
41
|
-
分两件事做,顺序不能反:**先立保真边界,再动词**。
|
|
42
|
-
|
|
43
|
-
| 场景 | 用哪个技能 | 做什么 |
|
|
44
|
-
|---|---|---|
|
|
45
|
-
| 中文长文 / 公众号改稿 | `humanizer-zh` + `qu-aiwei-zh` | 先立边界,再扫描定位(默认组合) |
|
|
46
|
-
| 只要分数和命中清单 | `qu-aiwei-zh` | 跑脚本,不改稿 |
|
|
47
|
-
| 随笔要「加声音」 | `qu-aiwei-zh` + `qu-aiwei-zh/references/pan-style.md` | 用表达方式加,不编故事 |
|
|
48
|
-
| 英文文本 | `humanizer` | `humanizer-zh` 只针对中文 |
|
|
49
|
-
| 清单 / 词表 / 说明书 | 都不用 E 类姿态分 | 这类文体靠列举反例,检测器会把反例当真命中 |
|
|
50
|
-
|
|
51
|
-
### 第一步:保真边界(`humanizer-zh`)
|
|
52
|
-
|
|
53
|
-
`qu-aiwei-zh` 管「像不像人写的」,`humanizer-zh` 管「改完意思有没有变」。31 条检查点每条都带「保留」边界,**是检查线索不是词语黑名单**——命中不等于要改,回上下文判断是否空泛、重复、歧义或与文体不符。
|
|
54
|
-
|
|
55
|
-
动词前先钉住这些:
|
|
56
|
-
|
|
57
|
-
- 不新增原文没有的事实、数字、人名、日期、来源、经历、实现细节
|
|
58
|
-
- 不丢失否定、比较对象、范围、条件、时间、完成状态、归因
|
|
59
|
-
- 「可能」不改成「确定」,「计划」不改成「已经完成」,「相关」不改成「因果」
|
|
60
|
-
- 三项列表 / 四字格 / 破折号 / 被动句按作用判断,不确定作用就保留
|
|
61
|
-
|
|
62
|
-
### ⚠️ 分数不够时不许补事实
|
|
63
|
-
|
|
64
|
-
`qu-aiwei-zh` 的结构维度会因「全文无数字」扣 12 分、「全文无第一人称」扣 8 分。**这两条是诊断,不是待办**:
|
|
65
|
-
|
|
66
|
-
- 原文确实没有数字 → 分数就停在那儿,**不许为了拿分编一个数**
|
|
67
|
-
- 原文是不需要第一人称的客观报道 → **不许硬塞「我」**
|
|
68
|
-
|
|
69
|
-
抬分只有三条路:删套话、改表达、调节奏。补数字只有两条路:一手来源已在手,或用户提供。够不着就不补。
|
|
70
|
-
|
|
71
|
-
### 第二步:扫描定位(`qu-aiwei-zh`)
|
|
72
|
-
|
|
73
|
-
目标:`qu-aiwei-zh` 扫描 **总分 ≥ 85**,且**「作者姿态分」(E 类)也 ≥ 85**。两个分数独立,都要看。
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
python3 scripts/scan_ai_flavor.py <稿>.md
|
|
77
|
-
# 或直连 canonical(同一个脚本)
|
|
78
|
-
python3 ~/.workbuddy/skills/qu-aiwei-zh/scripts/scan_ai_flavor.py <稿>.md
|
|
79
|
-
```
|
|
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
|
-
|
|
87
|
-
### 删套话
|
|
88
|
-
|
|
89
|
-
值得注意 / 值得记录 / 值得单独强调 / 本质上 / 事实上 / 意味着 / 由此可见 / 不难看出 / 换句话说 / 换言之 / 殊途同归 / 显而易见 / 众所周知 / 更进一步说
|
|
90
|
-
|
|
91
|
-
改成直述。例:「最值得个人记住的其实是复盘里的一个细节」→「对个人最有参考价值的其实是复盘里的一个细节」→ 更好的是直接说细节是什么。
|
|
92
|
-
|
|
93
|
-
### 删黑话
|
|
94
|
-
|
|
95
|
-
护城河、生态位、闭环、打法、颗粒度、赋能、沉淀、赛道、倒逼、组合拳、卡位、底层逻辑
|
|
96
|
-
|
|
97
|
-
换法:护城河→优势;闭环→这一轮/整条链;打法→做法;赛道→方向;沉淀→积累/形成。
|
|
98
|
-
|
|
99
|
-
**不要误伤专业用法**:「口径」(官方口径/社区口径)、「对齐」(alignment)、「收敛」(数学语义)都是正常的。
|
|
100
|
-
|
|
101
|
-
### 降调段末金句
|
|
102
|
-
|
|
103
|
-
`**能搬走的数据,才是真正属于你的数据。**` → `能搬走的数据,才是真正属于你的数据。`
|
|
104
|
-
|
|
105
|
-
删完套话却满篇加粗金句,等于从「AI 体」变成「新闻通稿」——同样没人味。
|
|
106
|
-
|
|
107
|
-
### 保留的东西
|
|
108
|
-
|
|
109
|
-
- **「不是 A 而是 B」多数要保留**:「你要的是同事,不是神」「沙箱不是边界,是概率」都是有信息量的反转,只删纯修辞的重复
|
|
110
|
-
- **三段排比若内容扎实可留**,硬凑的才砍到两项或四项
|
|
111
|
-
|
|
112
|
-
### 扫描器的两个误报(别照着改坏)
|
|
113
|
-
|
|
114
|
-
`qu-aiwei-zh` 会把两类东西误判成 AI 味,改之前先自己看一遍原文:
|
|
115
|
-
|
|
116
|
-
- **「排比三段式」误报数据陈述**:`从 15 个精简到 2 个,准确率从 80% 升到…`、`从发布、被审计、更正到上线` 是时间/数据变化,不是修辞排比,**保留**。
|
|
117
|
-
- **「复盘」误报专有名词**:「37signals 退出云复盘」里的「复盘」是标题/专名,不改;只有「我们来复盘一下」这种动词套话才改。
|
|
118
|
-
|
|
119
|
-
判断优先级:**先看语义是不是真排比/真套话,再决定删不删**,不要按扫描器命中的字面动手。
|
|
120
|
-
|
|
121
|
-
### 加人味(只加表达,不加事实)
|
|
122
|
-
|
|
123
|
-
有「我」、有具体场景、有具体数字与人名、有不确定性表达(「其实」「不过」「还在想」)、句子长短交替、有真实细节而不是「很多人」「大多数」。
|
|
124
|
-
|
|
125
|
-
⚠️ 这一段说的是**表达方式**。原文没有第一人称、没有数字时,不许靠这一条去补——那正好踩中 `humanizer-zh` 的优先级第 1 条。加声音的手段是句长节奏、设问、括号自注(见 `qu-aiwei-zh/references/pan-style.md`),配合删掉元叙述和教学腔。`humanizer-zh` 里写着同一句话:「用户要求增强个人风格时,用表达方式实现,不编故事。」
|
|
126
|
-
|
|
127
|
-
英文文本才走 `humanizer`:它的取向是 **Add soul**(主动注入个性),与中文改稿的**保真优先**方向相反,别把它的思路套到中文稿上。
|
|
128
|
-
|
|
129
|
-
### 工具纪律
|
|
130
|
-
|
|
131
|
-
**并行编辑同一文件会互相覆盖**——同一文件的多处修改必须走**单个串行脚本**,改完立刻复核关键字段是否落盘(曾丢过 8 处,含 star 数与日期两处关键修正)。
|
|
132
|
-
|
|
133
|
-
## 四、改稿红线
|
|
134
|
-
|
|
135
|
-
不改技术事实、数据、评测结论、代码块内容。改稿改的是阅读节奏与表达,不是重写。
|
|
136
|
-
|
|
137
|
-
外部工具改过的稿子(用户先拿别的模型改过一版再交回来):**先核数字,再动文字,顺序不能反**。实测一轮揪出 7 处错值,其中 5 处是「比值对但绝对值错」——比值正确会让错误绝对值显得可信。判据:出现比值对而绝对值错,说明写的人是从别的口径倒推,该段所有数字都要重核。
|
|
@@ -1,366 +0,0 @@
|
|
|
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())
|