master-skill 0.11.0 → 0.12.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +48 -55
- package/README_EN.md +72 -59
- package/bin/cli.mjs +12 -7
- package/gemini-extension.json +1 -1
- package/hooks/session-start +68 -77
- package/hooks/session_start.py +152 -0
- package/package.json +5 -2
- package/prebuilt/compare-masters/SKILL.md +21 -2
- package/prebuilt/master-ajahn-chah/meta.json +6 -0
- package/prebuilt/master-ajahn-chah/tests/fidelity.jsonl +6 -6
- package/prebuilt/master-atisha/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-curriculum/references/tiantai.md +1 -1
- package/prebuilt/master-debate/SKILL.md +14 -2
- package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
- package/prebuilt/master-help/SKILL.md +9 -1
- package/prebuilt/master-huineng/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-kumarajiva/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-mahasi-sayadaw/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-milarepa/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-nagarjuna/tests/fidelity.jsonl +6 -6
- package/prebuilt/master-ouyi/meta.json +5 -0
- package/prebuilt/master-ouyi/references/teaching.md +3 -3
- package/prebuilt/master-ouyi/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-tsongkhapa/meta.json +6 -0
- package/prebuilt/master-tsongkhapa/tests/fidelity.jsonl +2 -2
- package/prebuilt/master-xuanzang/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-xuyun/tests/fidelity.jsonl +6 -6
- package/prebuilt/master-zhiyi/meta.json +2 -2
- package/prebuilt/master-zhiyi/tests/fidelity.jsonl +2 -2
- package/scripts/check-audit-ignores.py +105 -0
- package/scripts/check-eval-sdk-surface.py +142 -0
- package/scripts/check-gate-liveness.py +205 -6
- package/scripts/reaudit-report.py +163 -0
- package/scripts/regrade-report.py +157 -0
- package/scripts/smoke-eval-sdk.py +174 -0
- package/scripts/test-fidelity.py +684 -52
- package/scripts/validate-citation-references.py +150 -0
- package/scripts/validate-citation-templates.py +176 -0
- package/scripts/validate-fixture-terms.py +127 -0
- package/scripts/verify-adjudication.py +316 -0
- package/scripts/verify_citations.py +739 -39
- package/tools/cross_reference.py +44 -10
- package/tools/fojin-known-absent.json +14 -0
- package/tools/fojin_bridge.py +138 -8
- package/tools/rag_query.py +45 -2
- package/tools/skill_writer.py +50 -7
- package/tools/verify_sources.py +240 -15
- package/hooks/tests/test_run_hook.sh +0 -114
- package/hooks/tests/test_run_hook_cmd.sh +0 -94
- package/hooks/tests/test_session_start.sh +0 -149
- package/scripts/tests/test_check_gate_liveness.py +0 -232
- package/scripts/tests/test_check_manifest_versions.py +0 -217
- package/scripts/tests/test_check_response.py +0 -190
- package/scripts/tests/test_debate_protocol.py +0 -159
- package/scripts/tests/test_fidelity_providers.py +0 -202
- package/scripts/tests/test_injection_hardening.py +0 -174
- package/scripts/tests/test_select_fidelity_smoke.py +0 -142
- package/scripts/tests/test_validate.py +0 -145
- package/scripts/tests/test_validate_citation_contract.py +0 -408
- package/scripts/tests/test_validate_cross_critique.py +0 -149
- package/scripts/tests/test_validate_curriculum_sources.py +0 -144
- package/scripts/tests/test_validate_fidelity.py +0 -59
- package/scripts/tests/test_validate_lore_triggers_content.py +0 -372
- package/scripts/tests/test_validate_persona_fidelity.py +0 -317
- package/scripts/tests/test_validate_promptfoo_configs.py +0 -386
- package/scripts/tests/test_validate_workflow.py +0 -284
|
@@ -19,43 +19,455 @@
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import argparse
|
|
22
|
+
import functools
|
|
22
23
|
import json
|
|
23
24
|
import os
|
|
24
25
|
import re
|
|
25
26
|
import sys
|
|
27
|
+
import threading
|
|
28
|
+
import unicodedata
|
|
29
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
30
|
+
from typing import NamedTuple
|
|
26
31
|
|
|
27
|
-
from _masterpaths import resolve_master_dir
|
|
32
|
+
from _masterpaths import PREBUILT as PREBUILT_ROOT, resolve_master_dir
|
|
28
33
|
|
|
29
34
|
# master feeds into path resolution; restrict to a slug charset so a value like
|
|
30
35
|
# "../../etc" can never read files outside prebuilt/. Mirrors the isSafeName
|
|
31
36
|
# guard in bin/cli.mjs and scripts/query.py.
|
|
32
37
|
_SAFE_MASTER = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
33
38
|
|
|
34
|
-
# CBETA id 形态:T48n2008 / T08n0235(藏经卷+n+编号)
|
|
35
|
-
# (
|
|
39
|
+
# CBETA id 形态:T48n2008 / T08n0235(藏经卷+n+编号)、嘉兴藏
|
|
40
|
+
# J36nB348(字母 B 是目录号的一部分),及 API 返回的 X1218 / X0303 / JB348
|
|
41
|
+
# (无卷号)。无 `n` 的形态只认 T/X 纯数字与 J+B编号,避免误吞 Wikidata 的
|
|
42
|
+
# Q1234 / P5008。
|
|
36
43
|
#
|
|
37
44
|
# 边界不能用 \b:Python 的 \w 覆盖 CJK,故「卷一T99n9999」的「一」与「T」之间
|
|
38
45
|
# 没有 \b,整块引文会被 audit_answer 当作无 id 跳过 —— 而格式跑偏正是模型最可能
|
|
39
46
|
# 编造经号的时候。改判「前后不是拉丁字母或数字」:汉字紧邻属真实引文形态,须命中;
|
|
40
47
|
# 拉丁字母紧邻(FakeSutraT99n9999)通常意味着它只是更长 token 的一部分,不算引文。
|
|
41
48
|
_CBETA_ID = re.compile(
|
|
42
|
-
r"(?<![0-9A-Za-z])(?:
|
|
49
|
+
r"(?<![0-9A-Za-z])(?:"
|
|
50
|
+
r"[A-Z]{1,2}\d+n[A-Z]?\d+[a-z]?|[TX]\d{3,}|JB\d{3,}"
|
|
51
|
+
r")(?![0-9A-Za-z])"
|
|
43
52
|
)
|
|
53
|
+
# 非 CBETA 契约家族。roadmap Phase 2 承诺把 CBETA / BDRC·Toh / PTS·SuttaCentral /
|
|
54
|
+
# 编集开示当作平等的 contract family,但审计器长期只实现了第一个 —— 于是全部藏传与
|
|
55
|
+
# 南传祖师的伪造引用一律漏检(见 eval/reports/BASELINE.md 的撤回段)。
|
|
56
|
+
#
|
|
57
|
+
# 关键在归一化,不在正则:meta.json 声明 `Toh:4465`,行文写 `Toh 4465`;声明
|
|
58
|
+
# `BDRC:W22272`,夹具 must_cite 写裸 `W22272`。只加正则不归一,会把**正确**引用
|
|
59
|
+
# 判成伪造 —— 那比漏检更糟。统一收敛到 `Family:Work` 再与声明集比对。
|
|
60
|
+
_FAMILY_ID = re.compile(
|
|
61
|
+
r"(?<![0-9A-Za-z])(?:"
|
|
62
|
+
r"(?P<toh>Toh)[:\s]\s*(?P<toh_n>\d+)"
|
|
63
|
+
r"|(?P<bdrc>BDRC)[:\s]\s*(?P<bdrc_w>W[0-9A-Za-z-]+)"
|
|
64
|
+
# 真 PTS id 的作品名首字母大写(`PTS:Vism` / `PTS:DN-Comm`)。要求大写,
|
|
65
|
+
# 「(PTS edition)」这类散文说明才不会被读成 id。
|
|
66
|
+
r"|(?P<pts>PTS)[:\s]\s*(?P<pts_w>[A-Z][0-9A-Za-z-]*)"
|
|
67
|
+
# 裸 W 号:BDRC 的 work id 常单独出现(W22272 / W1KG14334)。要求 W 后紧跟数字,
|
|
68
|
+
# 否则 "Wisdom Publications" 这类普通词会被误读成 id。
|
|
69
|
+
r"|(?P<bare_w>W\d[0-9A-Z-]{3,})"
|
|
70
|
+
r")(?![0-9A-Za-z])"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalize_family_id(m: "re.Match[str]") -> str:
|
|
75
|
+
"""把一处家族引文归一成 meta.json 里声明的 `Family:Work` 形态。"""
|
|
76
|
+
if m.group("toh"):
|
|
77
|
+
return f"Toh:{m.group('toh_n')}"
|
|
78
|
+
if m.group("bdrc"):
|
|
79
|
+
return f"BDRC:{m.group('bdrc_w')}"
|
|
80
|
+
if m.group("pts"):
|
|
81
|
+
return f"PTS:{m.group('pts_w')}"
|
|
82
|
+
return f"BDRC:{m.group('bare_w')}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# 学术引用常把大正藏经号写成「T31 No.1585」「T 48, no. 2008」—— 藏别字母、册号、
|
|
86
|
+
# `No.`、经号。`_CBETA_ID` 只认 `T31n1585` 这一种拼写,于是这类引文整块进
|
|
87
|
+
# `unparsed`:真引用没被核对,编造的也抓不到。存档里实测四处:2026-08-31 全量运行
|
|
88
|
+
# `compare-masters` 一处 `T48 No.2008`;2026-09-13 元技能复测三处 `T31 No.1585`
|
|
89
|
+
# ×2、`T31 No.1614`。
|
|
90
|
+
#
|
|
91
|
+
# 归一成声明集的拼写(`T31n1585`,册号补到两位、经号补到四位),再交给同一套比对
|
|
92
|
+
# ——包括只在册号、经号、字母后缀全部相等时才放行的未补零分支。这里不另开放行口子,
|
|
93
|
+
# 只让这种写法进得了审计。
|
|
94
|
+
#
|
|
95
|
+
# `[0-9]` 而非 `\d`:这是解析器,理由同下方 `_SHORT_FORM` —— 全角数字不得经 `int()`
|
|
96
|
+
# 洗成一个「已核验」的真经号。
|
|
97
|
+
_CBETA_NO_FORM = re.compile(
|
|
98
|
+
r"(?<![0-9A-Za-z])([TXJ])\s?([0-9]{1,3}),?\s*[Nn][Oo]\.\s*(B?[0-9]{1,5})(?![0-9A-Za-z])"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _canonical_no_form(match: re.Match) -> str:
|
|
103
|
+
canon, volume, number = match.group(1), int(match.group(2)), match.group(3)
|
|
104
|
+
work = number if number.startswith("B") else f"{int(number):04d}"
|
|
105
|
+
return f"{canon}{volume:02d}n{work}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def extract_citation_ids(text: str) -> list[str]:
|
|
109
|
+
"""抽出一段文本里所有可核对的来源 id,四个家族一视同仁。
|
|
110
|
+
|
|
111
|
+
CBETA 号按原样返回(声明形态与行文形态本来就一致);其余家族归一到
|
|
112
|
+
`Family:Work`。SuttaCentral 是**语料库级** id(runtime contract §4 规则 3),
|
|
113
|
+
`MN 118` 这类经号无法在没有经目索引的情况下判真伪,故不在此抽取 —— 那是
|
|
114
|
+
已知边界,不是遗漏。
|
|
115
|
+
"""
|
|
116
|
+
ids = list(_CBETA_ID.findall(text))
|
|
117
|
+
ids.extend(_canonical_no_form(m) for m in _CBETA_NO_FORM.finditer(text))
|
|
118
|
+
ids.extend(_normalize_family_id(m) for m in _FAMILY_ID.finditer(text))
|
|
119
|
+
return ids
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# 短号 ↔ 完整号。CBETA 通行简写省掉册号(`T1911` = `T46n1911`,
|
|
123
|
+
# `JB348` = `J36nB348`),而 meta.json 存完整形态。审计无条件运行之后,
|
|
124
|
+
# 模型写简写就会被误判伪造。按「藏别字母 + 经号」对齐,跨藏不对齐;
|
|
125
|
+
# Jiaxing 的 B 前缀必须保留。
|
|
126
|
+
# `[0-9]`,不是 `\d`。这两个正则**不是**识别器,是**解析器** —— 它们把行文里的
|
|
127
|
+
# 短号对回声明集,命中即判 offline(已核验)。所以上面那条「识别 id 宽松是安全的」
|
|
128
|
+
# 在这里**不成立**:`\d` 吃全角,`int()` 又把 `T1911` 归一成 1911,于是一个在
|
|
129
|
+
# 任何平台都解析不出来的引文串被报成「已核验的声明源」—— 不是多抓一条判伪造,
|
|
130
|
+
# 是 over-resolve 成了通过。
|
|
131
|
+
#
|
|
132
|
+
# `{1,8}`:`int()` 在 4300 位以上抛 ValueError,而这条路跑在 check_response 里,
|
|
133
|
+
# 一条 `【x,T999…(5000个9)】` 能把整轮付费评测掀翻。经号最长四五位,8 位够宽。
|
|
134
|
+
_SHORT_FORM = re.compile(r"^([TXJ])(B?[0-9]{1,8})$")
|
|
135
|
+
_FULL_FORM = re.compile(r"^([TXJ])[0-9]{1,8}n(B?[0-9]{1,8})[a-z]?$")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _normalize_cbeta_work_number(number: str) -> str:
|
|
139
|
+
"""Strip numeric zero padding while retaining a Jiaxing `B` prefix."""
|
|
140
|
+
prefix = "B" if number.startswith("B") else ""
|
|
141
|
+
digits = number[1:] if prefix else number
|
|
142
|
+
return f"{prefix}{int(digits)}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# 完整形但作品号未补零:`T14n475` 之于声明的 `T14n0475`。CBETA 习惯把作品号
|
|
146
|
+
# 补到四位,模型常照写不补 —— 这不是短号(`T2008` 那种没有卷号的形态),所以
|
|
147
|
+
# `_SHORT_FORM` 匹配不上,`_resolve_short_form` 直接返回 None,于是一条**正确**
|
|
148
|
+
# 引用被判成**伪造**。2026-09-13 探针实测:master-debate 一条回答里 4 个引文块,
|
|
149
|
+
# 3 个栽在这上面。判伪造比漏检更糟 —— 漏检只是没看,判伪造是指着真话说假话。
|
|
150
|
+
_UNPADDED_FULL_FORM = re.compile(r"^([TXJ])([0-9]{1,8})n(B?[0-9]{1,8})([a-z]?)$")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_short_form(cid: str, declared_ids: set[str]) -> str | None:
|
|
154
|
+
"""把短号 / 未补零的完整号对回声明里的完整号;对不上返回 None(仍按伪造处理)。"""
|
|
155
|
+
m = _SHORT_FORM.match(cid)
|
|
156
|
+
if m:
|
|
157
|
+
prefix = m.group(1)
|
|
158
|
+
number = _normalize_cbeta_work_number(m.group(2))
|
|
159
|
+
for declared in declared_ids:
|
|
160
|
+
d = _FULL_FORM.match(declared)
|
|
161
|
+
if (
|
|
162
|
+
d
|
|
163
|
+
and d.group(1) == prefix
|
|
164
|
+
and _normalize_cbeta_work_number(d.group(2)) == number
|
|
165
|
+
):
|
|
166
|
+
return declared
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
full = _UNPADDED_FULL_FORM.match(cid)
|
|
170
|
+
if not full:
|
|
171
|
+
return None
|
|
172
|
+
# 比短号那一支**更严**:卷号也必须相等。否则 `T99n0475` 会冒充
|
|
173
|
+
# `T14n0475` —— 卷号写错的引用不该被洗白成正确的。
|
|
174
|
+
prefix, volume, number, suffix = (
|
|
175
|
+
full.group(1),
|
|
176
|
+
full.group(2).lstrip("0") or "0",
|
|
177
|
+
_normalize_cbeta_work_number(full.group(3)),
|
|
178
|
+
full.group(4),
|
|
179
|
+
)
|
|
180
|
+
matches = []
|
|
181
|
+
for declared in declared_ids:
|
|
182
|
+
d = _UNPADDED_FULL_FORM.match(declared)
|
|
183
|
+
if (
|
|
184
|
+
d
|
|
185
|
+
and d.group(1) == prefix
|
|
186
|
+
and (d.group(2).lstrip("0") or "0") == volume
|
|
187
|
+
and _normalize_cbeta_work_number(d.group(3)) == number
|
|
188
|
+
and d.group(4) == suffix
|
|
189
|
+
):
|
|
190
|
+
matches.append(declared)
|
|
191
|
+
# 歧义时不放行。现有声明集里归一后没有撞号(实测 35 条完整形,0 处冲突),
|
|
192
|
+
# 但放行凭据必须失败即安全 —— 将来多一条就会撞。
|
|
193
|
+
return matches[0] if len(matches) == 1 else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# 第四个契约家族:编集开示(compiled_teaching)。声明形态是 `Author:Work`
|
|
197
|
+
# (`AjahnChah:StillForestPool`),而行文里它只以书名出现(《A Still Forest Pool》)——
|
|
198
|
+
# 没有编号可抓,所以这一家必须拿声明集当钥匙来读,不能像前三家那样先抽 id 再比对。
|
|
199
|
+
#
|
|
200
|
+
# 难点仍是归一化。三处真实差异,每一处只要漏掉就会把**正确**引用判成伪造:
|
|
201
|
+
# 声明 StillForestPool ← 行文《A Still Forest Pool》 冠词
|
|
202
|
+
# 声明 ProgressOfInsight ← 行文《The Progress of Insight (Visuddhiñāṇa-kathā)》 冠词 + 括注
|
|
203
|
+
# 声明 PracticalVipassana ← 行文《Practical Vipassanā Meditation Exercises》 变音符 + 副标题
|
|
204
|
+
# 故:去括注 → 去变音符 → 切词 → 去首冠词 → 声明词序为行文词序的**前缀**才算命中。
|
|
205
|
+
# 前缀是有向的:声明比行文长不算命中,否则《Practical》就能冒充整本书。
|
|
206
|
+
_WORK_TITLE = re.compile(r"《([^》]+)》")
|
|
207
|
+
_PARENTHETICAL = re.compile(r"[((][^))]*[))]")
|
|
208
|
+
_CJK = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]")
|
|
209
|
+
# 前三家的家族名不是作者名,排除掉;`Toh:4465` 这类纯数字 Work 也不是书名。
|
|
210
|
+
_ID_FAMILIES = {"Toh", "BDRC", "PTS"}
|
|
211
|
+
_ARTICLES = {"a", "an", "the"}
|
|
212
|
+
# 语料库级的 SuttaCentral 经号也常写进书名号里(《MN 10 / Satipaṭṭhāna Sutta》)。
|
|
213
|
+
# 它是拉丁字面、又不会匹配任何编集开示,若不先挡掉就会被这一家判成伪造 ——
|
|
214
|
+
# 阿姜查自己的 references/teaching.md 里就有四条。经号真伪需要经目索引才能判,
|
|
215
|
+
# 那是 runtime contract §4 规则 3 划出的已知边界,此处维持「读不懂」而非「伪造」。
|
|
216
|
+
_SUTTA_REF = re.compile(
|
|
217
|
+
r"^(?:SC[::]\s*)?(?:MN|SN|AN|DN|KN|Dhp|Ud|Iti|Snp|Thag|Thig|Vin)\s*\d",
|
|
218
|
+
re.IGNORECASE,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _fold(text: str) -> str:
|
|
223
|
+
"""去掉变音符号:Vipassanā → Vipassana、Visuddhiñāṇa → Visuddhinana。"""
|
|
224
|
+
decomposed = unicodedata.normalize("NFKD", text)
|
|
225
|
+
return "".join(c for c in decomposed if not unicodedata.combining(c))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _title_tokens(title: str) -> list[str]:
|
|
229
|
+
"""行文书名 → 归一词序。"""
|
|
230
|
+
words = re.findall(r"[A-Za-z]+", _fold(_PARENTHETICAL.sub(" ", title)).lower())
|
|
231
|
+
if words and words[0] in _ARTICLES:
|
|
232
|
+
words = words[1:]
|
|
233
|
+
return words
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _work_tokens(work: str) -> list[str]:
|
|
237
|
+
"""声明里的 CamelCase Work → 归一词序。FoodForTheHeart → food for the heart。"""
|
|
238
|
+
words = [w.lower() for w in re.findall(r"[A-Z][a-z]*|[a-z]+", _fold(work))]
|
|
239
|
+
if words and words[0] in _ARTICLES:
|
|
240
|
+
words = words[1:]
|
|
241
|
+
return words
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _compiled_works(declared_ids: set[str]) -> dict[str, tuple[str, ...]]:
|
|
245
|
+
"""声明集里属编集开示家族的 id → 其归一词序。"""
|
|
246
|
+
works: dict[str, tuple[str, ...]] = {}
|
|
247
|
+
for declared in declared_ids:
|
|
248
|
+
family, _, work = declared.partition(":")
|
|
249
|
+
if not work or family in _ID_FAMILIES or work.isdigit():
|
|
250
|
+
continue
|
|
251
|
+
tokens = _work_tokens(work)
|
|
252
|
+
if tokens:
|
|
253
|
+
works[declared] = tuple(tokens)
|
|
254
|
+
return works
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# 合集覆盖成员。声明的编集开示有时是一部合集,note 里点了具体成员篇目的名
|
|
258
|
+
# ("Mālukyaputta Sutta / Dhammacakka Sutta / Sallekha Sutta 等开示集") ——
|
|
259
|
+
# 人格引某一篇具体开示时用的是篇目名,不是合集自己的 CamelCase 名。2026-09-03
|
|
260
|
+
# 裁定:合集自己 note 点过名的成员,应可解析回它所属的合集。
|
|
261
|
+
#
|
|
262
|
+
# 形状而非内容驱动:note 里没有 `/` 就不是成员列表(多数 note 只是单条书目
|
|
263
|
+
# 说明,如「1944 缅文巨著…」),不必解析。单字词片段(裸的 "Sutta")几乎
|
|
264
|
+
# 每条引用都会命中,要求至少两个词才够格当别名。
|
|
265
|
+
_MEMBER_MIN_WORDS = 2
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# CJK ideographs (_CJK's own range) plus CJK punctuation/fullwidth blocks
|
|
269
|
+
# (U+3000-303F, U+FF00-FFEF) -- both split a note segment into non-CJK
|
|
270
|
+
# candidate pieces. Ideographs alone are not enough: a real note ends one
|
|
271
|
+
# member's title with a full-width comma before trailing publisher info
|
|
272
|
+
# ("Sallekha Sutta <CJK note>, BPS Sri Lanka") -- not splitting on that comma
|
|
273
|
+
# lets the Latin tail glue onto the punctuation and out-length the real title.
|
|
274
|
+
# Built with \u escapes, not typed CJK literals: hand-typing ideograph range
|
|
275
|
+
# endpoints is easy to mistype silently (happened once here -- meant U+F900,
|
|
276
|
+
# typed a character that turned out to be U+8C48, widening the range to
|
|
277
|
+
# swallow everything in between).
|
|
278
|
+
_CJK_OR_PUNCT = re.compile("[" + _CJK.pattern[1:-1] + "\u3000-\u303f\uff00-\uffef]")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def extract_member_aliases(collection_id: str, note: str) -> dict[str, str]:
|
|
282
|
+
"""把合集来源的 note 解析成「成员篇目名 → 合集 id」的别名表。
|
|
283
|
+
|
|
284
|
+
每段取**被 CJK(表意字+标点)切开后最长的那一块**,而非「第一个 CJK 字符
|
|
285
|
+
之前」——后者假设英文标题总在 CJK 说明之前,「开示集包括 X」这类中文在前
|
|
286
|
+
的写法会被整段吞掉、标题静默丢失。丢失是安全的方向(该词仍按未声明处理,
|
|
287
|
+
不会被错误洗白),但没必要留着这个假设。
|
|
288
|
+
"""
|
|
289
|
+
if "/" not in note:
|
|
290
|
+
return {}
|
|
291
|
+
aliases: dict[str, str] = {}
|
|
292
|
+
for segment in note.split("/"):
|
|
293
|
+
pieces = _CJK_OR_PUNCT.split(segment)
|
|
294
|
+
title = max((p.strip() for p in pieces), key=len, default="")
|
|
295
|
+
if len(re.findall(r"[A-Za-z]+", title)) < _MEMBER_MIN_WORDS:
|
|
296
|
+
continue
|
|
297
|
+
aliases[title] = collection_id
|
|
298
|
+
return aliases
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _compiled_teaching_id(
|
|
302
|
+
block: str,
|
|
303
|
+
declared_ids: set[str],
|
|
304
|
+
member_aliases: dict[str, str] | None = None,
|
|
305
|
+
) -> str | None:
|
|
306
|
+
"""把一个书名块判给编集开示家族。
|
|
307
|
+
|
|
308
|
+
命中声明的整部作品 → 返回那条声明 id(记 offline);命中某合集 note 里
|
|
309
|
+
点名的成员篇目 → 返回该合集的 id;本 master 确实有编集开示来源、而这个
|
|
310
|
+
拉丁书名两者都不命中 → 返回书名本身(交给 audit_answer 按伪造处理);
|
|
311
|
+
家族不适用(汉文书名,或本 master 根本没有编集开示来源)→ None,维持原样。
|
|
312
|
+
"""
|
|
313
|
+
works = _compiled_works(declared_ids)
|
|
314
|
+
if not works:
|
|
315
|
+
return None
|
|
316
|
+
m = _WORK_TITLE.search(block)
|
|
317
|
+
if not m:
|
|
318
|
+
return None
|
|
319
|
+
title = m.group(1)
|
|
320
|
+
if _CJK.search(title) or not re.search(r"[A-Za-z]", title):
|
|
321
|
+
return None
|
|
322
|
+
if _SUTTA_REF.match(title.strip()):
|
|
323
|
+
return None
|
|
324
|
+
cited = _title_tokens(title)
|
|
325
|
+
if not cited:
|
|
326
|
+
return None
|
|
327
|
+
# 最长匹配优先,不按字典序:一个声明作品的词序若恰是另一个声明作品词序的
|
|
328
|
+
# 前缀(如 Food / FoodForTheHeart),字典序会把引用错记到更短、字典序更靠
|
|
329
|
+
# 前的那一个。按前缀长度降序取第一个命中,才是「最贴合」的那条声明。
|
|
330
|
+
prefix_matches = [
|
|
331
|
+
(declared, tokens)
|
|
332
|
+
for declared, tokens in works.items()
|
|
333
|
+
if tuple(cited[: len(tokens)]) == tokens
|
|
334
|
+
]
|
|
335
|
+
if prefix_matches:
|
|
336
|
+
return max(prefix_matches, key=lambda pair: len(pair[1]))[0]
|
|
337
|
+
# 成员别名只按「结尾」匹配,不接受别名之后还有多余文字。真实形态是
|
|
338
|
+
# 「描述性前缀 + 成员全名」("A Discourse on Dhammacakka Sutta"),全名
|
|
339
|
+
# 在尾部;若接受别名出现在任意位置,一个编造标题只要恰好包含真成员名的
|
|
340
|
+
# 子串就会被洗白("...Dhammacakka Sutta That Does Not Exist")。
|
|
341
|
+
for member, collection in sorted((member_aliases or {}).items()):
|
|
342
|
+
needle = tuple(_title_tokens(member))
|
|
343
|
+
if needle and len(needle) <= len(cited) and tuple(cited[-len(needle):]) == needle:
|
|
344
|
+
return collection
|
|
345
|
+
return f"《{title}》"
|
|
346
|
+
|
|
347
|
+
|
|
44
348
|
# 引文块 【…】
|
|
45
349
|
_CITATION_BLOCK = re.compile(r"【([^】]*)】")
|
|
350
|
+
# 一个【…】块要算「引文」,至少得含书名号、拉丁字母或数字 —— 本仓的引文契约
|
|
351
|
+
# 里每条引用都指名一部作品(《…》)或一个标识符。人格也拿【…】当小标题写
|
|
352
|
+
# (`【辨名义】`、`【立宗】`、`【破异说】`,以及把问题原样复述一遍),那些既不是
|
|
353
|
+
# 引文,也不该进 `unparsed`:它们会把分母撑大、把覆盖率压低,让一份诚实的报告
|
|
354
|
+
# 显得比实际差。2026-09-12 那轮 629 个块里有 10 个是这种。
|
|
355
|
+
#
|
|
356
|
+
# 不静默丢弃 —— 单列一类回传。整块 silently skip 正是当年让「审计了但一条都
|
|
357
|
+
# 没看懂」和「已审计、干净」在报告里长得一模一样的那个动作。
|
|
358
|
+
_LOOKS_LIKE_CITATION = re.compile(r"《|[A-Za-z0-9]")
|
|
46
359
|
# live 链接 fojin.app/texts/<数字>
|
|
47
|
-
|
|
360
|
+
#
|
|
361
|
+
# `[0-9]` 而不是 `\d`:Python 的 `\d` 默认吃全部 Unicode 数字,于是
|
|
362
|
+
# `fojin.app/texts/123`(全角)曾被判成 live —— 一条未声明的伪造经号,靠一个
|
|
363
|
+
# fojin.app 路由根本打不开的链接就被洗白。`--online`(唯一会去解析该 id 的路径)
|
|
364
|
+
# 是可选的,CI 硬门只跑离线判定,永远不会发现。
|
|
365
|
+
#
|
|
366
|
+
# 注意这里与上面几个 id 正则的**方向相反**,不要顺手一起改:
|
|
367
|
+
# 识别 id 宽松 → 多抓 → 判 fabricated → 失败,安全;
|
|
368
|
+
# 放行链接宽松 → 多放 → 洗白伪造引文 → 通过,危险。
|
|
369
|
+
# 所以「放行凭据」这一个要往死里收紧,`_CBETA_ID` / `_FAMILY_ID` 保持宽松。
|
|
370
|
+
#
|
|
371
|
+
# 只收紧数字还不够 —— 原来是个**无锚子串**匹配,于是这两个都能放行伪造引文:
|
|
372
|
+
# https://evil.example.com/?ref=fojin.app/texts/123
|
|
373
|
+
# https://myfojin.app/texts/123
|
|
374
|
+
# 现在要求 `fojin.app` 紧跟在 `://` 之后,且 `://` 前面不能再接路径字符
|
|
375
|
+
# (挡掉 `https://evil.com/https://fojin.app/...`)。全仓真实引用一律写
|
|
376
|
+
# `https://fojin.app/texts/N`(实测 251 处无一例外),裸域名只出现在注释与散文里,
|
|
377
|
+
# 所以要求 scheme 不会误伤正确引用。
|
|
378
|
+
# `{1,10}`:上限存在是因为 `int()` 在 4300 位以上抛 ValueError,而调用它的
|
|
379
|
+
# `_resolve_short_form` 跑在 check_response 里,一条畸形引文能掀翻整轮付费评测。
|
|
380
|
+
_FOJIN_TEXT_LINK = re.compile(
|
|
381
|
+
r"(?<![\w./-])https?://fojin\.app/texts/([0-9]{1,10})(?![0-9])"
|
|
382
|
+
)
|
|
48
383
|
# 引文块「之后」多远内出现 live 链接仍算本块携带(且不跨过下一引文块)。link 须在引文之后。
|
|
49
384
|
_LINK_WINDOW = 120
|
|
50
385
|
|
|
51
386
|
|
|
52
|
-
|
|
53
|
-
|
|
387
|
+
_FRONTMATTER_KIND = re.compile(r"^kind:\s*[\"']?([A-Za-z-]+)", re.M)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _skill_kind(master_dir: str) -> str | None:
|
|
391
|
+
"""技能类型:先看 meta.json 的 `kind`,没有再看 SKILL.md frontmatter。
|
|
392
|
+
|
|
393
|
+
两处都声明 `kind`,而三个元技能(compare-masters / master-curriculum /
|
|
394
|
+
master-help)只有 SKILL.md 没有 meta.json —— 只读 meta.json 就会把它们
|
|
395
|
+
当成"找不到",于是 load_declared_ids 抛异常、审计整个跳过。
|
|
396
|
+
"""
|
|
397
|
+
meta_path = os.path.join(master_dir, "meta.json")
|
|
398
|
+
if os.path.exists(meta_path):
|
|
399
|
+
with open(meta_path, encoding="utf-8") as f:
|
|
400
|
+
kind = json.load(f).get("kind")
|
|
401
|
+
if kind:
|
|
402
|
+
return kind
|
|
403
|
+
skill_path = os.path.join(master_dir, "SKILL.md")
|
|
404
|
+
if os.path.exists(skill_path):
|
|
405
|
+
with open(skill_path, encoding="utf-8") as f:
|
|
406
|
+
head = f.read(2048)
|
|
407
|
+
m = _FRONTMATTER_KIND.search(head)
|
|
408
|
+
if m:
|
|
409
|
+
return m.group(1)
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _persona_slugs(base: str | None = None) -> list[str]:
|
|
414
|
+
"""`prebuilt/` 下所有**非**元技能的目录名。"""
|
|
415
|
+
root = base if base is not None else PREBUILT_ROOT
|
|
416
|
+
out = []
|
|
417
|
+
for name in sorted(os.listdir(root)):
|
|
418
|
+
path = os.path.join(root, name)
|
|
419
|
+
if not os.path.isdir(path):
|
|
420
|
+
continue
|
|
421
|
+
if _skill_kind(path) == "meta-skill":
|
|
422
|
+
continue
|
|
423
|
+
if os.path.exists(os.path.join(path, "meta.json")):
|
|
424
|
+
out.append(name)
|
|
425
|
+
return out
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _union_over_personas(loader, base: str | None = None):
|
|
429
|
+
"""把 loader 施加到每一位 persona 上并合并结果 —— 元技能的声明集。
|
|
430
|
+
|
|
431
|
+
compare-masters / master-debate / master-curriculum 引的是**别人**的经论,
|
|
432
|
+
自己不声明来源。以前这意味着 `load_declared_ids` 要么抛 FileNotFoundError
|
|
433
|
+
(没有 meta.json),要么返回空集(debate 的 meta.json 只有协议没有 sources),
|
|
434
|
+
两条路都让调用方的 `if declared_ids:` 短路,审计整个不跑。2026-09-12 那轮
|
|
435
|
+
因此有 53 条引文(compare-masters 47 + curriculum 6)被发出去、一条未查。
|
|
436
|
+
|
|
437
|
+
**这个并集严格弱于逐 persona 核验。** 审计时手上只有答案正文,不知道当次
|
|
438
|
+
选了哪两位祖师,所以「把慧能的经号安到宗喀巴名下」这类张冠李戴它查不出来。
|
|
439
|
+
它能查出的是真正的幻觉引文 —— 全库都没声明过的经号。比不查强,比逐 persona
|
|
440
|
+
弱,不要把它当成后者。
|
|
441
|
+
"""
|
|
442
|
+
merged = None
|
|
443
|
+
for slug in _persona_slugs(base):
|
|
444
|
+
part = loader(slug, base)
|
|
445
|
+
if merged is None:
|
|
446
|
+
merged = part.copy()
|
|
447
|
+
elif isinstance(merged, dict):
|
|
448
|
+
for k, v in part.items():
|
|
449
|
+
merged.setdefault(k, v)
|
|
450
|
+
else:
|
|
451
|
+
merged |= part
|
|
452
|
+
return merged if merged is not None else type(loader("", base))()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def load_declared_ids(master: str, base: str | None = None) -> set[str]:
|
|
456
|
+
"""读 <base or prebuilt>/<master>/meta.json,返回声明的离线 cbeta_id 集合。
|
|
457
|
+
|
|
458
|
+
`base` 默认 None,交给 resolve_master_dir 用它自己的真实 prebuilt/ 常量 ——
|
|
459
|
+
传别的目录是为了让调用方(如 validate-citation-references.py 的
|
|
460
|
+
find_undeclared)既能用真实仓库,也能在测试里指向 tmp_path,不必为了可测试性
|
|
461
|
+
自己重新实现一遍 meta.json 解析。
|
|
462
|
+
"""
|
|
54
463
|
if not _SAFE_MASTER.match(master):
|
|
55
464
|
raise ValueError(f"无效的 master ID:{master!r}(仅允许字母、数字、'-'、'_')")
|
|
56
|
-
|
|
465
|
+
kwargs = {"base": base} if base is not None else {}
|
|
466
|
+
master_dir = resolve_master_dir(master, **kwargs) # 兼容 "huineng" / "master-huineng"
|
|
57
467
|
if master_dir is None:
|
|
58
468
|
raise FileNotFoundError(f"找不到 master:{master!r}(试过 {master!r} 和 master-{master})")
|
|
469
|
+
if _skill_kind(master_dir) == "meta-skill":
|
|
470
|
+
return _union_over_personas(load_declared_ids, base)
|
|
59
471
|
with open(os.path.join(master_dir, "meta.json"), encoding="utf-8") as f:
|
|
60
472
|
meta = json.load(f)
|
|
61
473
|
ids: set[str] = set()
|
|
@@ -67,53 +479,328 @@ def load_declared_ids(master: str) -> set[str]:
|
|
|
67
479
|
return ids
|
|
68
480
|
|
|
69
481
|
|
|
70
|
-
def
|
|
71
|
-
"""
|
|
482
|
+
def load_member_aliases(master: str, base: str | None = None) -> dict[str, str]:
|
|
483
|
+
"""读 <base or prebuilt>/<master>/meta.json,把每条编集开示来源的 note 解析成
|
|
484
|
+
「成员篇目 → 合集 id」别名表(合并全部来源)。找不到 note 或没有
|
|
485
|
+
`/` 分隔的来源不贡献别名 —— 参见 extract_member_aliases。`base` 见
|
|
486
|
+
load_declared_ids。"""
|
|
487
|
+
if not _SAFE_MASTER.match(master):
|
|
488
|
+
raise ValueError(f"无效的 master ID:{master!r}(仅允许字母、数字、'-'、'_')")
|
|
489
|
+
kwargs = {"base": base} if base is not None else {}
|
|
490
|
+
master_dir = resolve_master_dir(master, **kwargs)
|
|
491
|
+
if master_dir is None:
|
|
492
|
+
raise FileNotFoundError(f"找不到 master:{master!r}(试过 {master!r} 和 master-{master})")
|
|
493
|
+
if _skill_kind(master_dir) == "meta-skill":
|
|
494
|
+
return _union_over_personas(load_member_aliases, base)
|
|
495
|
+
with open(os.path.join(master_dir, "meta.json"), encoding="utf-8") as f:
|
|
496
|
+
meta = json.load(f)
|
|
497
|
+
aliases: dict[str, str] = {}
|
|
498
|
+
for src in meta.get("sources", []):
|
|
499
|
+
if src.get("type") != "compiled_teaching":
|
|
500
|
+
continue
|
|
501
|
+
sid, note = src.get("id"), src.get("note")
|
|
502
|
+
if sid and note:
|
|
503
|
+
aliases.update(extract_member_aliases(sid, note))
|
|
504
|
+
return aliases
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def load_title_aliases(master: str, base: str | None = None) -> dict[str, str]:
|
|
508
|
+
"""「声明题名 → 声明 id」别名表,只对**非经号**来源生成。
|
|
509
|
+
|
|
510
|
+
宗喀巴声明 `Lam-rim-chen-mo`,答案写 `【《菩提道次第广论》§毗钵舍那章】`;
|
|
511
|
+
声明 `Lam-gtso-rnam-gsum`,答案写 `【《三主要道》(Lam gtso rnam gsum)】`
|
|
512
|
+
—— 空格而非连字符。两者都是**正确引用了已声明来源**,解析器一条都认不出,
|
|
513
|
+
全进 `unparsed`。2026-09-12 那轮里这是宗喀巴 53 条引文中的 50 条。
|
|
514
|
+
|
|
515
|
+
别名就摆在 meta.json 里:`"title": "菩提道次第广论 (Lam rim chen mo)"`
|
|
516
|
+
同时给出中文题名与空格式 Wylie。这里只是把已经声明的东西读出来。
|
|
517
|
+
|
|
518
|
+
**经号家族一律不生成别名。** CBETA 契约要求的标识符是经号本身,若
|
|
519
|
+
`【《六祖坛经》】` 能靠题名过关,那条契约就被这个"修复"悄悄削掉了。判断
|
|
520
|
+
依据是 id 的形状(`_CBETA_ID` 整体匹配),不是 `type` 字段:type 是自由
|
|
521
|
+
文本,形状是审计器真正认的东西。
|
|
522
|
+
|
|
523
|
+
这道守卫跳过了 46 条经号来源,而 2026-09-12 那轮**去掉它覆盖率一字不变**
|
|
524
|
+
—— 那批答案里没有一条是只写书名不写经号的。所以它同样是前瞻护栏,不是
|
|
525
|
+
已测修复,由 test_a_cbeta_persona_may_not_cite_by_title_alone 钉住。
|
|
526
|
+
"""
|
|
527
|
+
if not _SAFE_MASTER.match(master):
|
|
528
|
+
raise ValueError(f"无效的 master ID:{master!r}(仅允许字母、数字、'-'、'_')")
|
|
529
|
+
kwargs = {"base": base} if base is not None else {}
|
|
530
|
+
master_dir = resolve_master_dir(master, **kwargs)
|
|
531
|
+
if master_dir is None:
|
|
532
|
+
raise FileNotFoundError(f"找不到 master:{master!r}(试过 {master!r} 和 master-{master})")
|
|
533
|
+
if _skill_kind(master_dir) == "meta-skill":
|
|
534
|
+
return _union_over_personas(load_title_aliases, base)
|
|
535
|
+
with open(os.path.join(master_dir, "meta.json"), encoding="utf-8") as f:
|
|
536
|
+
meta = json.load(f)
|
|
537
|
+
aliases: dict[str, str] = {}
|
|
538
|
+
for src in meta.get("sources", []):
|
|
539
|
+
sid, title = src.get("id"), src.get("title")
|
|
540
|
+
if not sid or not title or _CBETA_ID.fullmatch(sid):
|
|
541
|
+
continue
|
|
542
|
+
inner = re.findall(r"[((]([^))]+)[))]", title)
|
|
543
|
+
head = re.sub(r"\s*[((].*$", "", title).strip()
|
|
544
|
+
# 一条 title 可能把多个题名粘在一起
|
|
545
|
+
# (`"《吉祥悦意》《长部注释》 (Sumaṅgalavilāsinī)"`),整串永远匹配不上
|
|
546
|
+
# 正文里的单个题名,所以把每个《…》也各登记一次。
|
|
547
|
+
parts = [head, *inner]
|
|
548
|
+
parts += [t for chunk in parts for t in _WORK_TITLE.findall(chunk)]
|
|
549
|
+
for alias in parts:
|
|
550
|
+
alias = alias.strip()
|
|
551
|
+
if len(alias) >= _MIN_TITLE_ALIAS:
|
|
552
|
+
aliases.setdefault(alias, sid)
|
|
553
|
+
return aliases
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# 题名别名的最短长度。2026-09-12 那轮实测(每次清 __pycache__ 后重跑):
|
|
557
|
+
# 下限 4 → 523/603 下限 3 → 526 下限 2 → 530 下限 1 → 530
|
|
558
|
+
# 差额是《入中论》(3 字,Toh:3861)与《父法》《子法》(2 字,BDRC:Pha-chos-Bu-chos)
|
|
559
|
+
# —— 都是声明里原样写着的书名,挡掉它们纯属损失。取 2 而不取 1,是因为单字
|
|
560
|
+
# 别名(若将来有人声明一条 title 为「论」「经」的来源)会在散文里到处撞上;
|
|
561
|
+
# 现有数据里没有单字题名,所以这一格差别为 0 —— 它是前瞻护栏,由
|
|
562
|
+
# test_a_one_character_title_never_becomes_an_alias 钉住,不是一处已测修复。
|
|
563
|
+
_MIN_TITLE_ALIAS = 2
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@functools.lru_cache(maxsize=8)
|
|
567
|
+
def _declared_literal_matcher(declared: frozenset[str]) -> re.Pattern[str] | None:
|
|
568
|
+
"""A matcher for declared ids written exactly as declared.
|
|
72
569
|
|
|
73
|
-
|
|
570
|
+
`_FAMILY_ID` needs a family tag or a numeric work id, and four of this
|
|
571
|
+
repo's own declared ids have neither: `master-tsongkhapa` declares bare
|
|
572
|
+
Wylie titles (`Lam-rim-chen-mo`, `sNgags-rim-chen-mo`,
|
|
573
|
+
`Drang-nges-legs-bshad-snying-po`, `Lam-gtso-rnam-gsum`), and two more are
|
|
574
|
+
`BDRC:` followed by Wylie rather than a W-number (`BDRC:gsung-bum`,
|
|
575
|
+
`BDRC:Pha-chos-Bu-chos`). No regex reads a hyphenated Tibetan title apart
|
|
576
|
+
from prose, so the parser dropped all six into `unparsed`.
|
|
577
|
+
|
|
578
|
+
Re-auditing the 2026-09-12 sweep, that was every remaining failure in
|
|
579
|
+
`master-curriculum` — 26 of 32 parsed, and the 6 that did not were all
|
|
580
|
+
declared. `unparsed` reads as neutral, so a correct citation of a declared
|
|
581
|
+
source was scored the same as an uninterpretable one.
|
|
582
|
+
|
|
583
|
+
This runs only where nothing at all was extractable, and matches only a
|
|
584
|
+
string already in the declared set, so it can move a citation from
|
|
585
|
+
`unparsed` to `offline` and never from `fabricated`. The shortest declared
|
|
586
|
+
id is 8 characters, so there is nothing here short enough to collide with
|
|
587
|
+
running prose.
|
|
588
|
+
"""
|
|
589
|
+
if not declared:
|
|
590
|
+
return None
|
|
591
|
+
alts = "|".join(re.escape(i) for i in sorted(declared, key=len, reverse=True))
|
|
592
|
+
return re.compile(rf"(?<![0-9A-Za-z-]){alts}(?![0-9A-Za-z-])")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def audit_answer(
|
|
596
|
+
declared_ids: set[str],
|
|
597
|
+
answer: str,
|
|
598
|
+
member_aliases: dict[str, str] | None = None,
|
|
599
|
+
title_aliases: dict[str, str] | None = None,
|
|
600
|
+
) -> dict:
|
|
601
|
+
"""把答案里每条引文分类为 offline / live / fabricated / unparsed。
|
|
602
|
+
|
|
603
|
+
返回 {'offline': [...], 'live': [(cbeta_id, text_id), ...],
|
|
604
|
+
'fabricated': [...], 'unparsed': [...], 'noncitation': [...]}。
|
|
605
|
+
|
|
606
|
+
``noncitation`` 是**根本不是引文**的【…】块 —— 人格拿它当小标题
|
|
607
|
+
(`【辨名义】`)或复述问题。它们不计入覆盖率的分母;单列而非丢弃,是为了
|
|
608
|
+
「这里有 10 个块我判定它不是引文」这句话在报告里说得出口。
|
|
609
|
+
|
|
610
|
+
``unparsed`` 是抽不出任何可核对 id 的引文块。它们以前被整块 silently skip,
|
|
611
|
+
于是「审计了但一条都没看懂」和「已审计、干净」在报告里长得一模一样 —— 宗喀巴
|
|
612
|
+
声明的多是裸 Wylie 标题、觉音大量用语料库级的 `【SC: AN 3.88】`,两者的
|
|
613
|
+
``fabricated`` 都是空。记下来才能算出「审计器实际看得懂的引用占多少」。
|
|
74
614
|
"""
|
|
75
615
|
offline: list[str] = []
|
|
76
616
|
live: list[tuple[str, str]] = []
|
|
77
617
|
fabricated: list[str] = []
|
|
618
|
+
unparsed: list[str] = []
|
|
619
|
+
noncitation: list[str] = []
|
|
78
620
|
|
|
79
621
|
blocks = list(_CITATION_BLOCK.finditer(answer))
|
|
80
622
|
for idx, m in enumerate(blocks):
|
|
81
|
-
|
|
82
|
-
if not ids:
|
|
83
|
-
continue
|
|
84
|
-
# 链接归属:本引文块结束 → 下一引文块开始(且不超过 _LINK_WINDOW)。这样一个 link
|
|
623
|
+
# 归属区:本引文块结束 → 下一引文块开始(且不超过 _LINK_WINDOW)。这样一个 link
|
|
85
624
|
# 只能洗白紧挨它之前的那一个引文块,不会连带洗白更前面的伪造引文(B1 的关键)。
|
|
86
625
|
next_start = blocks[idx + 1].start() if idx + 1 < len(blocks) else len(answer)
|
|
87
626
|
region_end = min(next_start, m.end() + _LINK_WINDOW)
|
|
88
|
-
|
|
627
|
+
# 非 CBETA 祖师的主格式把家族标签放在块**后**的括号里
|
|
628
|
+
# (`【《Visuddhimagga》§I】(PTS Vism)`),所以 id 与 link 共用这同一个归属区。
|
|
629
|
+
ids = extract_citation_ids(m.group(1)) + extract_citation_ids(
|
|
630
|
+
answer[m.end():region_end]
|
|
631
|
+
)
|
|
632
|
+
if not ids:
|
|
633
|
+
compiled = _compiled_teaching_id(m.group(1), declared_ids, member_aliases)
|
|
634
|
+
if compiled:
|
|
635
|
+
ids = [compiled]
|
|
636
|
+
if not ids:
|
|
637
|
+
# Last resort: the block may name a declared id the parser has no
|
|
638
|
+
# pattern for. Exact membership only — see the matcher's docstring.
|
|
639
|
+
matcher = _declared_literal_matcher(frozenset(declared_ids))
|
|
640
|
+
if matcher is not None:
|
|
641
|
+
ids = matcher.findall(m.group(1)) + matcher.findall(
|
|
642
|
+
answer[m.end():region_end]
|
|
643
|
+
)
|
|
644
|
+
if not ids and title_aliases:
|
|
645
|
+
# 声明题名 → 声明 id。同样只在什么都抽不出来时才走,而且映射的
|
|
646
|
+
# 目标必然在 declared_ids 里,所以只能把 unparsed 挪成 offline。
|
|
647
|
+
block = m.group(1)
|
|
648
|
+
hit = [
|
|
649
|
+
(alias, sid)
|
|
650
|
+
for alias, sid in title_aliases.items()
|
|
651
|
+
if alias in block
|
|
652
|
+
]
|
|
653
|
+
if hit:
|
|
654
|
+
# 最长题名优先:「密宗道次第广论」包含「宗道次第广论」这类
|
|
655
|
+
# 前缀关系下,短的那个会把引用记到错的声明上。
|
|
656
|
+
ids = [max(hit, key=lambda pair: len(pair[0]))[1]]
|
|
657
|
+
if not ids:
|
|
658
|
+
block = m.group(1).strip()
|
|
659
|
+
if block:
|
|
660
|
+
if _LOOKS_LIKE_CITATION.search(block):
|
|
661
|
+
unparsed.append(block)
|
|
662
|
+
else:
|
|
663
|
+
noncitation.append(block)
|
|
664
|
+
continue
|
|
665
|
+
links = _FOJIN_TEXT_LINK.findall(answer, m.end(), region_end)
|
|
666
|
+
# 先分类,再决定 link 能洗白谁 —— 一个链接只能为**一条**引文作保。
|
|
667
|
+
# 原来 `for cid in ids` 复用同一个 `link`,于是
|
|
668
|
+
# 【《伪甲》,T99n9991;《伪乙》,T99n9992;《伪丙》,T99n9993】+ 一个真链接
|
|
669
|
+
# 会让三条伪造经号一起过关:那个链接指向的显然只是其中一部经(如果有的话),
|
|
670
|
+
# 其余两条连"它到底指谁"都无从谈起。歧义时判伪造 —— 放行凭据必须失败即安全。
|
|
671
|
+
unresolved: list[str] = []
|
|
89
672
|
for cid in ids:
|
|
90
|
-
if cid in declared_ids
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
673
|
+
resolved = cid if cid in declared_ids else _resolve_short_form(
|
|
674
|
+
cid, declared_ids
|
|
675
|
+
)
|
|
676
|
+
if resolved:
|
|
677
|
+
offline.append(resolved)
|
|
94
678
|
else:
|
|
95
|
-
|
|
96
|
-
|
|
679
|
+
unresolved.append(cid)
|
|
680
|
+
if len(unresolved) == 1 and links:
|
|
681
|
+
live.append((unresolved[0], links[0]))
|
|
682
|
+
else:
|
|
683
|
+
fabricated.extend(unresolved)
|
|
684
|
+
return {
|
|
685
|
+
"offline": offline,
|
|
686
|
+
"live": live,
|
|
687
|
+
"fabricated": fabricated,
|
|
688
|
+
"unparsed": unparsed,
|
|
689
|
+
"noncitation": noncitation,
|
|
690
|
+
}
|
|
691
|
+
|
|
97
692
|
|
|
693
|
+
# 每个 id 的核验结果是**三态**,不是布尔:
|
|
694
|
+
# True → 200 + 有 JSON 正文,确实解析得到
|
|
695
|
+
# False → 404,平台明确说没有这个 id —— 唯一该硬失败的信号
|
|
696
|
+
# None → 5xx / 连接失败 / 返回的不是 JSON,不知道
|
|
697
|
+
# 旧版把这三种压成一个布尔,再让任何一次异常 `return {"_unreachable": True}`,
|
|
698
|
+
# 于是一次网络抖动就把**已经验成功的全部结果丢掉**,整轮降级成一条警告。
|
|
699
|
+
# "查不出来" 和 "查过了,没问题" 从此长得一样 —— 正是本仓一直在修的那个形状。
|
|
700
|
+
VERIFY_TIMEOUT = 15
|
|
701
|
+
VERIFY_WORKERS = 8
|
|
98
702
|
|
|
99
|
-
|
|
703
|
+
|
|
704
|
+
def _check_one_text_id(session_factory, base_url: str, tid: str, timeout: int):
|
|
705
|
+
"""核验单个 text_id,返回 (三态结果, 说明)。异常一律收敛成 None,不外抛。"""
|
|
706
|
+
try:
|
|
707
|
+
resp = session_factory().get(f"{base_url}/api/texts/{tid}", timeout=timeout)
|
|
708
|
+
except Exception as e: # noqa: BLE001 — 传输层失败是"不知道",不是"不存在"
|
|
709
|
+
return None, f"{type(e).__name__}: {e}"
|
|
710
|
+
if resp.status_code == 404:
|
|
711
|
+
return False, "404"
|
|
712
|
+
if resp.status_code != 200:
|
|
713
|
+
return None, f"HTTP {resp.status_code}"
|
|
714
|
+
try:
|
|
715
|
+
payload = resp.json()
|
|
716
|
+
except ValueError:
|
|
717
|
+
# 200 却不是 JSON:通常是网关错误页,不能据此断定 id 不存在。
|
|
718
|
+
return None, "200 但正文不是 JSON"
|
|
719
|
+
if payload:
|
|
720
|
+
return True, ""
|
|
721
|
+
# 200 + 空正文归 None,不归 False —— 上面刚写下「404 是唯一该硬失败的信号」,
|
|
722
|
+
# 这里返回 False 就是在自己的契约上开口子。空信封可能来自 FoJin 换了外层结构、
|
|
723
|
+
# 一次读半截、或 CDN 改写了 body;拿它判伪造,会在平台抖动时把**正确**引用
|
|
724
|
+
# 批量打成伪造 —— 同一段注释说过那比漏检更糟。真的不存在,平台会回 404。
|
|
725
|
+
return None, "200 但正文为空"
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
class OnlineVerification(NamedTuple):
|
|
729
|
+
"""一次 --online 核验的完整结果。
|
|
730
|
+
|
|
731
|
+
最初的版本把这些塞进**同一个字典**:`{tid: 三态, "_reasons": {...}}`,
|
|
732
|
+
再让调用方 pop 掉那个下划线键。也就是说字典里有一个键不是 text_id,而
|
|
733
|
+
「是不是 text_id」只能靠命名约定分辨 —— 这正是本仓一直在修的那种形状:
|
|
734
|
+
两种语义长得一样,靠调用方记得区分。忘了 pop 也不会炸,只会把 `_reasons`
|
|
735
|
+
当成一个永远核验通过的引文,静静地混进统计里。
|
|
736
|
+
"""
|
|
737
|
+
|
|
738
|
+
#: tid -> True(解析得到) / False(404,确定不存在) / None(查不出来)
|
|
739
|
+
verdicts: dict
|
|
740
|
+
#: tid -> 说明,只对 False / None 有值
|
|
741
|
+
reasons: dict
|
|
742
|
+
#: 整体不可达时的原因;可达时为 None
|
|
743
|
+
unreachable: str | None = None
|
|
744
|
+
|
|
745
|
+
@property
|
|
746
|
+
def fabricated(self) -> list:
|
|
747
|
+
"""平台明确回 404 的 —— 唯一该判失败的。"""
|
|
748
|
+
return sorted(t for t, ok in self.verdicts.items() if ok is False)
|
|
749
|
+
|
|
750
|
+
@property
|
|
751
|
+
def unknown(self) -> list:
|
|
752
|
+
"""传输层没给出答案的 —— 报出来,但不算伪造。"""
|
|
753
|
+
return sorted(t for t, ok in self.verdicts.items() if ok is None)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def verify_online(
|
|
757
|
+
text_ids: list[str],
|
|
758
|
+
base_url: str = "https://fojin.app",
|
|
759
|
+
timeout: int = VERIFY_TIMEOUT,
|
|
760
|
+
workers: int = VERIFY_WORKERS,
|
|
761
|
+
) -> OnlineVerification:
|
|
100
762
|
"""best-effort:GET /api/texts/{id} 看 live 引文的 text_id 是否真解析。
|
|
101
763
|
|
|
102
|
-
|
|
764
|
+
requests 缺失或**每一个** id 都查不出结果时,`unreachable` 带上原因;
|
|
765
|
+
部分失败不再吃掉部分成功。
|
|
103
766
|
"""
|
|
104
767
|
try:
|
|
105
768
|
import requests
|
|
106
769
|
except ImportError:
|
|
107
|
-
return {
|
|
770
|
+
return OnlineVerification({}, {}, "requests 未安装")
|
|
771
|
+
|
|
772
|
+
unique = sorted(set(text_ids))
|
|
773
|
+
if not unique:
|
|
774
|
+
return OnlineVerification({}, {})
|
|
775
|
+
|
|
776
|
+
# requests.Session 的线程安全性没有保证,所以每个线程持有自己的那个。
|
|
777
|
+
local = threading.local()
|
|
778
|
+
|
|
779
|
+
def session_factory():
|
|
780
|
+
if not hasattr(local, "session"):
|
|
781
|
+
local.session = requests.Session()
|
|
782
|
+
return local.session
|
|
783
|
+
|
|
108
784
|
out: dict = {}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
785
|
+
reasons: dict[str, str] = {}
|
|
786
|
+
with ThreadPoolExecutor(max_workers=max(1, min(workers, len(unique)))) as pool:
|
|
787
|
+
futures = {
|
|
788
|
+
pool.submit(_check_one_text_id, session_factory, base_url, tid, timeout): tid
|
|
789
|
+
for tid in unique
|
|
790
|
+
}
|
|
791
|
+
for future in as_completed(futures):
|
|
792
|
+
tid = futures[future]
|
|
793
|
+
out[tid], reason = future.result()
|
|
794
|
+
if reason:
|
|
795
|
+
reasons[tid] = reason
|
|
796
|
+
|
|
797
|
+
if all(v is None for v in out.values()):
|
|
798
|
+
first = next(iter(reasons.values()), "未知原因")
|
|
799
|
+
return OnlineVerification(
|
|
800
|
+
out, reasons, f"{len(out)} 个 id 全部无法核验({first})"
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
return OnlineVerification(out, reasons)
|
|
117
804
|
|
|
118
805
|
|
|
119
806
|
def main() -> int:
|
|
@@ -125,12 +812,14 @@ def main() -> int:
|
|
|
125
812
|
|
|
126
813
|
try:
|
|
127
814
|
declared = load_declared_ids(args.master)
|
|
815
|
+
member_aliases = load_member_aliases(args.master)
|
|
816
|
+
title_aliases = load_title_aliases(args.master)
|
|
128
817
|
except (ValueError, FileNotFoundError) as e:
|
|
129
818
|
print(f"✗ {e}", file=sys.stderr)
|
|
130
819
|
return 2
|
|
131
820
|
|
|
132
821
|
answer = open(args.answer_file, encoding="utf-8").read() if args.answer_file else sys.stdin.read()
|
|
133
|
-
report = audit_answer(declared, answer)
|
|
822
|
+
report = audit_answer(declared, answer, member_aliases, title_aliases)
|
|
134
823
|
|
|
135
824
|
print(f"offline 引文: {len(report['offline'])} live 引文: {len(report['live'])} "
|
|
136
825
|
f"fabricated: {len(report['fabricated'])}")
|
|
@@ -142,13 +831,24 @@ def main() -> int:
|
|
|
142
831
|
|
|
143
832
|
if args.online and report["live"]:
|
|
144
833
|
res = verify_online([tid for _, tid in report["live"]])
|
|
145
|
-
if res.
|
|
146
|
-
print(f"⚠ --online 跳过:FoJin 不可达({res.
|
|
834
|
+
if res.unreachable:
|
|
835
|
+
print(f"⚠ --online 跳过:FoJin 不可达({res.unreachable})", file=sys.stderr)
|
|
147
836
|
else:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
837
|
+
# 只有平台明确回 404 才算伪造 —— 5xx / 超时 / 网关错误页说明的是
|
|
838
|
+
# 网络状况,不是引文真伪,拿它判 fabricated 会在 FoJin 抖动时把正确
|
|
839
|
+
# 引用打成伪造,那比漏检更糟。
|
|
840
|
+
if res.fabricated:
|
|
841
|
+
print(f"✗ live 引文 text_id 无法解析: {res.fabricated}", file=sys.stderr)
|
|
151
842
|
exit_code = 1
|
|
843
|
+
if res.unknown:
|
|
844
|
+
# 报出来而不是静默算过 —— 「没查成」必须与「查过没问题」可区分。
|
|
845
|
+
detail = ", ".join(
|
|
846
|
+
f"{t}({res.reasons.get(t, '?')})" for t in res.unknown
|
|
847
|
+
)
|
|
848
|
+
print(
|
|
849
|
+
f"⚠ {len(res.unknown)} 条 live 引文未能核验: {detail}",
|
|
850
|
+
file=sys.stderr,
|
|
851
|
+
)
|
|
152
852
|
|
|
153
853
|
if exit_code == 0:
|
|
154
854
|
print("✓ 全部引文可核验")
|