@topmindspace/topmind-skills 4.14.0 → 4.15.1

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.
@@ -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())