master-skill 0.12.9 → 0.12.11
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/gemini-extension.json +1 -1
- package/package.json +2 -2
- package/prebuilt/master-buddhaghosa/references/teaching.md +1 -1
- package/prebuilt/master-huineng/references/teaching.md +2 -2
- package/prebuilt/master-huineng/references/voice.md +3 -3
- package/prebuilt/master-milarepa/SKILL.md +2 -0
- package/prebuilt/master-milarepa/meta.json +7 -1
- package/prebuilt/master-milarepa/references/teaching.md +13 -7
- package/prebuilt/master-milarepa/references/voice.md +4 -4
- package/prebuilt/master-milarepa/sources/INDEX.md +1 -1
- package/prebuilt/master-yinguang/references/voice.md +3 -3
- package/prebuilt/master-yinguang/sources/INDEX.md +1 -1
- package/prebuilt/master-yinguang/sources/yihanbianfu-excerpts.md +6 -5
- package/requirements.txt +2 -0
- package/scripts/check-gate-liveness.py +227 -0
- package/scripts/validate-quote-attribution.py +126 -0
- package/tools/compiled-teaching-sources.json +89 -0
- package/tools/fojin-known-absent.json +11 -0
- package/tools/verify_sources.py +405 -0
- package/tools/version_manager.py +13 -3
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
{
|
|
10
10
|
"name": "master-skill",
|
|
11
11
|
"description": "FoJin-powered Buddhist AI persona framework — source-grounded, boundary-aware, fidelity-tested, runtime-ready. 15 prebuilt masters across 印度/汉传/藏传/南传 plus compare, debate, and curriculum meta-skills.",
|
|
12
|
-
"version": "0.12.
|
|
12
|
+
"version": "0.12.11",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "xr843",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "master-skill",
|
|
3
3
|
"description": "FoJin-powered Buddhist AI persona framework — source-grounded, boundary-aware, fidelity-tested, runtime-ready. 15 prebuilt masters across 印度/汉传/藏传/南传 plus compare, debate, and curriculum meta-skills.",
|
|
4
|
-
"version": "0.12.
|
|
4
|
+
"version": "0.12.11",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "xr843",
|
|
7
7
|
"email": "xr843@users.noreply.github.com"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "master-skill",
|
|
3
3
|
"displayName": "Master Skill",
|
|
4
4
|
"description": "FoJin-powered Buddhist AI persona framework — source-grounded, boundary-aware, fidelity-tested, runtime-ready. 15 prebuilt masters across 印度/汉传/藏传/南传.",
|
|
5
|
-
"version": "0.12.
|
|
5
|
+
"version": "0.12.11",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "xr843",
|
|
8
8
|
"email": "xr843@users.noreply.github.com"
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "master-skill",
|
|
3
3
|
"description": "FoJin-powered Buddhist AI persona framework — source-grounded, boundary-aware, fidelity-tested, runtime-ready. 15 prebuilt masters across 印度/汉传/藏传/南传.",
|
|
4
|
-
"version": "0.12.
|
|
4
|
+
"version": "0.12.11",
|
|
5
5
|
"contextFileName": "GEMINI.md"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "master-skill",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "FoJin-powered Buddhist AI persona framework — source-grounded, boundary-aware, fidelity-tested, runtime-ready. 15 pre-built masters across 印度 / 汉传 / 藏传 / 南传, plus /compare-masters, /master-debate, and /master-curriculum.",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"validate:versions": "python3 scripts/check-manifest-versions.py",
|
|
17
17
|
"test:hook": "bash hooks/tests/test_session_start.sh && bash hooks/tests/test_run_hook.sh && bash hooks/tests/test_run_hook_cmd.sh",
|
|
18
18
|
"test:cli": "node --test tests/cli.test.mjs",
|
|
19
|
-
"test": "python3 scripts/check-gate-liveness.py && python3 scripts/validate.py --strict && python3 scripts/validate-citation-references.py && python3 scripts/validate-citation-templates.py && python3 scripts/validate-self-audit-sources.py && python3 scripts/validate-fidelity.py && python3 scripts/validate-persona-fidelity.py && python3 scripts/check-manifest-versions.py && python3 scripts/validate-routing.py && python3 scripts/validate-fixture-terms.py && python3 scripts/verify-adjudication.py && python3 scripts/test-fidelity.py --all --dry-run && node --test tests/cli.test.mjs && python3 -m pytest tests/ scripts/tests/ -q",
|
|
19
|
+
"test": "python3 scripts/check-gate-liveness.py && python3 scripts/validate.py --strict && python3 scripts/validate-citation-references.py && python3 scripts/validate-citation-templates.py && python3 scripts/validate-self-audit-sources.py && python3 scripts/validate-citation-contract.py && python3 scripts/validate-cross-critique.py && python3 scripts/validate-lore-triggers-content.py --strict && python3 scripts/validate-quote-attribution.py && python3 scripts/validate-promptfoo-configs.py && python3 scripts/validate-fidelity.py && python3 scripts/validate-persona-fidelity.py && python3 scripts/check-manifest-versions.py && python3 scripts/validate-routing.py && python3 scripts/validate-fixture-terms.py && python3 scripts/verify-adjudication.py && python3 scripts/test-fidelity.py --all --dry-run && node --test tests/cli.test.mjs && python3 -m pytest tests/ scripts/tests/ -q",
|
|
20
20
|
"test:smoke": "python3 scripts/test-fidelity.py --master yinguang --max-tests 1",
|
|
21
21
|
"prepack": "node bin/cli.mjs list"
|
|
22
22
|
},
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
|
|
91
91
|
### 菩提本无树偈
|
|
92
92
|
|
|
93
|
-
神秀偈:"身是菩提树,心如明镜台,时时勤拂拭,勿使惹尘埃。"慧能偈:"菩提本无树,明镜亦非台,本来无一物,何处惹尘埃。"
|
|
93
|
+
神秀偈:"身是菩提树,心如明镜台,时时勤拂拭,勿使惹尘埃。"慧能偈:"菩提本无树,明镜亦非台,本来无一物,何处惹尘埃。"(两偈并见《六祖大师法宝坛经·行由品》)神秀从有入手,着于渐修;慧能从空入手,直契本性。五祖因此密传衣钵。
|
|
94
94
|
|
|
95
95
|
**慧能大师的运用:** 以此说明自性本空,不可执著于"修"与"不修"的对立,直下承当。
|
|
96
96
|
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
|
|
105
105
|
### 一花开五叶
|
|
106
106
|
|
|
107
|
-
达摩祖师偈:"吾本来兹土,传法救迷情。一花开五叶,结果自然成。"
|
|
107
|
+
达摩祖师偈:"吾本来兹土,传法救迷情。一花开五叶,结果自然成。"(《六祖大师法宝坛经·付嘱品》引达摩付法偈)后禅宗果然至慧能后开出五家(临济、曹洞、沩仰、云门、法眼),此为禅宗传法的重要预言与象征。
|
|
108
108
|
|
|
109
109
|
## 关键术语表
|
|
110
110
|
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
慧能大师说法直接了当,不立文字但善用譬喻。语言口语化、平实朴素(本人不识字,以白话说法),同时极具穿透力。善用机锋——以反问、棒喝、出人意料的回答打破学人的思维惯性和概念执著。不做长篇大论的理论铺陈,而是直指要害,一语中的。
|
|
27
27
|
|
|
28
28
|
**示例句:**
|
|
29
|
-
1. "不是风动,不是幡动,仁者心动。"
|
|
30
|
-
2. "何期自性本自清净,何期自性本不生灭,何期自性本自具足,何期自性本无动摇,何期自性能生万法。"
|
|
31
|
-
3. "迷时师度,悟了自度。"
|
|
29
|
+
1. "不是风动,不是幡动,仁者心动。"(《六祖大师法宝坛经·行由品》)
|
|
30
|
+
2. "何期自性本自清净,何期自性本不生灭,何期自性本自具足,何期自性本无动摇,何期自性能生万法。"(《六祖大师法宝坛经·行由品》)
|
|
31
|
+
3. "迷时师度,悟了自度。"(《六祖大师法宝坛经·行由品》)
|
|
32
32
|
|
|
33
33
|
### 常用比喻
|
|
34
34
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"version": 1,
|
|
8
8
|
"claim_policy": "declared_sources_only",
|
|
9
9
|
"required_for": ["doctrinal_claim", "practice_guidance", "text_interpretation"],
|
|
10
|
-
"allowed_source_types": ["tibetan_canon"],
|
|
10
|
+
"allowed_source_types": ["cbeta", "tibetan_canon"],
|
|
11
11
|
"minimum_claim_coverage": 0.9,
|
|
12
12
|
"live_retrieval_allowed": true
|
|
13
13
|
},
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
"type": "tibetan_canon",
|
|
31
31
|
"id": "BDRC:W1GS56158",
|
|
32
32
|
"title": "密勒日巴尊者传(rNam thar)"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"type": "cbeta",
|
|
36
|
+
"id": "B11n0073",
|
|
37
|
+
"title": "木纳记(尊者传汉译,惹穹多杰札把著、张心若笔录)",
|
|
38
|
+
"note": "CBETA 大藏经补编第 11 册,29 卷;《密勒日巴尊者传》的 1930 年代汉译,含所录道歌。FoJin 未收"
|
|
33
39
|
}
|
|
34
40
|
],
|
|
35
41
|
"version": "1.0.0",
|
|
@@ -20,15 +20,17 @@
|
|
|
20
20
|
|
|
21
21
|
米拉日巴反复在道歌中强调四共加行——暇满难得、人身无常、业果不虚、轮回是苦。这是噶举派一切修持的起点,没有这四者的觉受,谈大手印都是空中楼阁。
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
尊者在戒学歌里说暇满难得:
|
|
24
24
|
|
|
25
|
-
>
|
|
25
|
+
> "人身难得无暇我亦知,思无常罪苦轮回自輭。"
|
|
26
|
+
|
|
27
|
+
> 出处:《木纳记》卷十一(B11n0073);《道歌集》相关诸歌见 BDRC W1KG1252
|
|
26
28
|
|
|
27
29
|
### 2. 出离心与精进闭关
|
|
28
30
|
|
|
29
31
|
噶举派以"实修"为命脉,米拉日巴以亲身雪山闭关树立典范。教导弟子:闻思之后必须实修,实修之根本在于具格上师指导下的长期闭关,不在文字辩论。
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
山居胜于名寺、粗食胜于丰筵,是《道歌集》闭关诸歌反复申说的意思。(转述,非原文;张澄基汉译本有版权,本仓库无法逐字核对。)
|
|
32
34
|
|
|
33
35
|
> 出处:《道歌集·闭关诸歌》主旨(BDRC W1KG1252)
|
|
34
36
|
|
|
@@ -36,9 +38,11 @@
|
|
|
36
38
|
|
|
37
39
|
噶举派被称为"口耳传承"(snyan brgyud),其根本依止上师亲口传授。米拉日巴反复教导:对上师的信心是一切证悟的根本,没有信心则一切教法皆不入心。玛尔巴对其的"折磨"(修建石塔)非懲罚,而是净除黑魔术业障的方便。
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
尊者负母骨离乡时长歌:
|
|
40
42
|
|
|
41
|
-
>
|
|
43
|
+
> "上师不动慈悲口,细译麻把教令勅。故乡有若牢狱鬼,无常如幻上师说,上师恩德最无上。"
|
|
44
|
+
|
|
45
|
+
> 出处:《木纳记》卷十四(B11n0073)
|
|
42
46
|
|
|
43
47
|
### 4. 那洛六法(Naro Chodruk)
|
|
44
48
|
|
|
@@ -54,9 +58,11 @@
|
|
|
54
58
|
|
|
55
59
|
米拉日巴的大手印不重经院辩证,而以道歌直指:
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
尊者为贡汤施主唱"六个力量"之歌:
|
|
62
|
+
|
|
63
|
+
> "此心犹如虚空遍,照境法身清净明。……无来无去无所住,三世平等一定住。心中无有生死因,本来清净如虚空。白红云堆自开散,四大不断如足迹。"
|
|
58
64
|
|
|
59
|
-
>
|
|
65
|
+
> 出处:《木纳记》卷二十三(B11n0073)
|
|
60
66
|
|
|
61
67
|
### 6. 觉受 (nyams) 与证悟 (rtogs pa) 的区分
|
|
62
68
|
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
米拉日巴尊者以**道歌(mGur)**说法,不立繁复经院术语。语言朴实、带浓厚山野气,善以**自然界譬喻**:雪山喻坚定、风喻无常、岩石喻法身、河流喻心相续。常以**亲身经历**为教材——早年咒杀仇家的罪业、玛尔巴上师的折磨、雪山中以荨麻为食的闭关。语气坦率、不矫饰,对修行人的怠惰直接呵斥,但对真诚求法者极为慈悲。
|
|
28
28
|
|
|
29
29
|
**示例句(皆有典籍出处):**
|
|
30
|
-
1. "
|
|
31
|
-
2. "
|
|
32
|
-
3. "
|
|
30
|
+
1. "人身难得无暇我亦知,思无常罪苦轮回自輭。"(《木纳记》卷十一)
|
|
31
|
+
2. "上师不动慈悲口,细译麻把教令勅。……上师恩德最无上。"(《木纳记》卷十四)
|
|
32
|
+
3. "此心犹如虚空遍,照境法身清净明。……本来清净如虚空。"(《木纳记》卷二十三)
|
|
33
33
|
|
|
34
|
-
>
|
|
34
|
+
> **重要**:以上三句皆为《木纳记》原文(简体转写,省略处用「……」)。大手印见修行果的四分说是后世噶举的归纳,不要写成尊者原话。
|
|
35
35
|
|
|
36
36
|
### 常用比喻
|
|
37
37
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
## 说明
|
|
18
18
|
|
|
19
19
|
- 所有片段节选自 BDRC(Buddhist Digital Resource Center)公开元数据所对应的藏文典籍
|
|
20
|
-
-
|
|
20
|
+
- 汉译参考:张澄基《密勒日巴大师全集》(慧炬出版社);CBETA 另收《木纳记》B11n0073(张心若笔录的尊者传汉译,29 卷,含所录道歌,FoJin 未收),人设所引原文据此
|
|
21
21
|
- 英译参考:Garma C. C. Chang, *The Hundred Thousand Songs of Milarepa*; Andrew Quintman, *The Life of Milarepa* (Penguin Classics)
|
|
22
22
|
- 用于教学引用,不代表完整经义;深入研究请查阅 BDRC、84000、Lotsawa House 全本
|
|
23
23
|
- **密法相关章节本目录刻意不收录**(拙火、那洛六法、生起圆满次第具体修法步骤),密法须依止具格上师亲传
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
印光大师以书信体弘法为主,文风恳切直接,文言白话兼用,严厉而不失慈悲。常以长者对晚辈的口吻说法,语重心长,不务虚华。
|
|
27
27
|
|
|
28
28
|
**示例句:**
|
|
29
|
-
1. "
|
|
30
|
-
2. "
|
|
31
|
-
3. "
|
|
29
|
+
1. "愿离娑婆,如狱囚之冀出牢狱。愿生极乐,如穷子之思归故乡。"(正编卷一·与徐福贤女士书)
|
|
30
|
+
2. "念佛之人,当吃长素。如或不能,当持六斋,或十斋。"(续编卷上·答曾怡芝居士四问)
|
|
31
|
+
3. "欲学佛法,先须克己慎独,事事皆从心地中真实做出。"(正编卷一·与丁福保居士书)
|
|
32
32
|
|
|
33
33
|
### 常用比喻
|
|
34
34
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
| 文件 | 来源经典 | CBETA | FoJin | 覆盖主题 |
|
|
8
8
|
|---|---|---|---|---|
|
|
9
9
|
| `wenchao-excerpts.md` | 《印光法師文鈔》 | 未收录 | — | 信愿行三资粮、摄耳谛听念佛、带业往生横超 |
|
|
10
|
-
| `yihanbianfu-excerpts.md` |
|
|
10
|
+
| `yihanbianfu-excerpts.md` | 《印光法師文鈔續編》(一函遍復、复唐能诚居士书) | 未收录 | — | 敦伦尽分、闲邪存诚、因果教育 |
|
|
11
11
|
| `jingtu-sanjing-excerpts.md` | 净土三经 | T12n0366/T12n0360 | [20](https://fojin.app/texts/20) / [18](https://fojin.app/texts/18) | 持名念佛、第十八愿、一心不乱 |
|
|
12
12
|
|
|
13
13
|
## 引用规范
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 《印光法師文鈔續編》关键片段(一函遍復、复唐能诚居士书)
|
|
2
2
|
|
|
3
3
|
> 印光大师著,出自《印光法師文鈔續編》。CBETA 与 FoJin 均未收录文钞,故无经号、无在线链接。
|
|
4
|
-
>
|
|
4
|
+
> 本文件为教学引用用。以下引文已于 2026-09-16 与殆知阁所藏《文鈔續編》全文逐字核对;
|
|
5
|
+
> 此前两段是真语拼接而成的改写,并非原文,已按原书改正。
|
|
5
6
|
|
|
6
7
|
## 敦伦尽分、闲邪存诚
|
|
7
8
|
|
|
8
9
|
原典(节选):
|
|
9
10
|
|
|
10
|
-
>
|
|
11
|
+
> 又须父慈,子孝,兄友,弟恭,夫和,妇顺,主仁,仆忠,恪尽己分。不计他对我之尽分与否,我总要尽我之分。能于家庭,及与社会,尽谊尽分,是名善人。……念佛最要紧,是敦伦尽分,闲邪存诚,诸恶莫作,众善奉行。存好心,说好话,行好事。
|
|
11
12
|
|
|
12
13
|
**引用格式:**【《印光法師文鈔續編》·一函遍復】
|
|
13
14
|
|
|
@@ -23,9 +24,9 @@
|
|
|
23
24
|
|
|
24
25
|
原典(节选):
|
|
25
26
|
|
|
26
|
-
>
|
|
27
|
+
> 吾常曰,因果者,圣人治天下,佛度众生之大权也。
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
**引用格式:**【《印光法師文鈔續編》·复唐能诚居士书】
|
|
29
30
|
|
|
30
31
|
**教义要点:**
|
|
31
32
|
- 因果为佛法入门之基础
|
package/requirements.txt
CHANGED
|
@@ -25,6 +25,7 @@ Usage:
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
27
|
import argparse
|
|
28
|
+
import ast
|
|
28
29
|
import functools
|
|
29
30
|
import json
|
|
30
31
|
import re
|
|
@@ -168,6 +169,75 @@ ADVISORY_GATES = {
|
|
|
168
169
|
),
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
# A gate that runs nowhere on a pull request has never guarded a change. This repo
|
|
173
|
+
# has shipped that twice: `validate-curriculum-sources.py` was "wired into no
|
|
174
|
+
# workflow, no npm script and no sub-check — only its own unit tests" (see the
|
|
175
|
+
# sub-check in validate.py that now runs it), and on 2026-09-16
|
|
176
|
+
# `validate-citation-templates.py` and `validate-self-audit-sources.py` were found
|
|
177
|
+
# to live only inside `npm test`, which only npm-publish.yml runs, on a published
|
|
178
|
+
# release. Both passed — the defect was latent, which is exactly why nothing
|
|
179
|
+
# surfaced it.
|
|
180
|
+
#
|
|
181
|
+
# Every entry script under scripts/ must therefore be reachable from a workflow
|
|
182
|
+
# that triggers on `pull_request`, or be declared here with the reason it is not.
|
|
183
|
+
# `check_every_gate_runs_on_a_pr` keeps this true in both directions.
|
|
184
|
+
NOT_A_PR_GATE = {
|
|
185
|
+
"cite.py": (
|
|
186
|
+
"a reader-facing offline lookup tool, documented in README.md and in the "
|
|
187
|
+
"personas' own SKILL.md; not a gate over repository content"
|
|
188
|
+
),
|
|
189
|
+
"query.py": (
|
|
190
|
+
"a reader-facing offline search tool, documented alongside cite.py; not a "
|
|
191
|
+
"gate over repository content"
|
|
192
|
+
),
|
|
193
|
+
"check-pe-subsystem.py": (
|
|
194
|
+
"inspects a built Windows executable, which exists only after the desktop "
|
|
195
|
+
"release build — release-desktop.yml is the only place it can run"
|
|
196
|
+
),
|
|
197
|
+
"reaudit-report.py": (
|
|
198
|
+
"re-audits a committed eval run's stored answers; run by hand after a paid "
|
|
199
|
+
"sweep, against a report that does not exist on a PR"
|
|
200
|
+
),
|
|
201
|
+
"regrade-report.py": (
|
|
202
|
+
"re-grades a committed eval run against the current judge; same as "
|
|
203
|
+
"reaudit-report.py — it needs a report a PR does not produce"
|
|
204
|
+
),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# `npm test` is what CONTRIBUTING tells a contributor to run before touching
|
|
209
|
+
# scripts/, in its own words 「避免在 CI 才发现」. A command that exists to pre-empt CI
|
|
210
|
+
# has to cover what CI checks. It has fallen behind twice: pytest was missing from it
|
|
211
|
+
# until 2026-09-03, and on 2026-09-16 four content gates the PR job runs —
|
|
212
|
+
# validate-citation-contract, validate-cross-critique, validate-lore-triggers-content
|
|
213
|
+
# and validate-quote-attribution — were absent, so a contributor could go green
|
|
214
|
+
# locally and still be failed by CI.
|
|
215
|
+
#
|
|
216
|
+
# Anything the per-PR job runs must therefore appear in `npm test` too, or be
|
|
217
|
+
# declared here. `check_npm_test_covers_pr_gates` keeps this true in both directions.
|
|
218
|
+
NOT_IN_NPM_TEST = {
|
|
219
|
+
"check-eval-sdk-surface.py": (
|
|
220
|
+
"asserts the pinned eval SDKs still expose what test-fidelity.py calls — it "
|
|
221
|
+
"needs requirements-eval.txt installed, which a content contributor has no "
|
|
222
|
+
"reason to have"
|
|
223
|
+
),
|
|
224
|
+
"smoke-eval-sdk.py": (
|
|
225
|
+
"stands up a local server for a keyless end-to-end SDK smoke; same eval-only "
|
|
226
|
+
"dependency, and far slower than the content gates around it"
|
|
227
|
+
),
|
|
228
|
+
"select-fidelity-smoke.py": (
|
|
229
|
+
"picks which persona the CI smoke grades from job metadata — a CI scheduling "
|
|
230
|
+
"helper, not a check over repository content"
|
|
231
|
+
),
|
|
232
|
+
"check-audit-ignores.py": (
|
|
233
|
+
"takes the cargo-audit JSON as an argument — security-scan.yml runs "
|
|
234
|
+
"`cargo audit --file desktop/Cargo.lock --json > audit.json` first. Without a "
|
|
235
|
+
"Rust toolchain and the advisory database there is nothing for it to read; "
|
|
236
|
+
"run bare it exits 2 on argparse usage"
|
|
237
|
+
),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
171
241
|
# The shape of a silent skip: a step that exits 0 because a secret is missing.
|
|
172
242
|
_SKIP_ON_MISSING_SECRET = re.compile(r'\[\s+-z\s+"\$\{[A-Z_]+:-\}"\s+\]')
|
|
173
243
|
|
|
@@ -251,6 +321,161 @@ def check_declared_gates_still_exist(workflow_docs: dict[str, dict]) -> list[str
|
|
|
251
321
|
# ---------------------------------------------------------------------------
|
|
252
322
|
|
|
253
323
|
|
|
324
|
+
def _script_references(source: str, scripts: set[str]) -> set[str]:
|
|
325
|
+
"""Which other scripts this source actually runs — imports and loaded filenames.
|
|
326
|
+
|
|
327
|
+
Both spellings are in use: `validate.py` loads five siblings through
|
|
328
|
+
`spec_from_file_location(..., "validate-curriculum-sources.py")`, which puts the
|
|
329
|
+
literal filename in the source, while `verify_citations.py` is pulled in as
|
|
330
|
+
`from verify_citations import …`. Counting only workflow text would report both
|
|
331
|
+
as unreachable and invent a defect where there is none.
|
|
332
|
+
|
|
333
|
+
Read through `ast`, not as text. The first version matched filenames anywhere in
|
|
334
|
+
the source and reported five scripts as reachable on the strength of *comments*:
|
|
335
|
+
`verify_citations.py` mentions "scripts/query.py" in a comment about a shared
|
|
336
|
+
guard, and verify_citations is imported by a job the PR runs, so query.py came
|
|
337
|
+
out "reachable". Comments do not survive parsing, and an exact-match on string
|
|
338
|
+
constants keeps a docstring that merely names a path from counting as a call.
|
|
339
|
+
"""
|
|
340
|
+
try:
|
|
341
|
+
tree = ast.parse(source)
|
|
342
|
+
except SyntaxError: # pragma: no cover — a syntactically broken script
|
|
343
|
+
return set()
|
|
344
|
+
|
|
345
|
+
wanted = {name: {name, f"scripts/{name}"} for name in scripts}
|
|
346
|
+
modules = {name[:-3]: name for name in scripts if "-" not in name[:-3]}
|
|
347
|
+
hit: set[str] = set()
|
|
348
|
+
for node in ast.walk(tree):
|
|
349
|
+
if isinstance(node, ast.ImportFrom) and node.module in modules:
|
|
350
|
+
hit.add(modules[node.module])
|
|
351
|
+
elif isinstance(node, ast.Import):
|
|
352
|
+
for alias in node.names:
|
|
353
|
+
if alias.name in modules:
|
|
354
|
+
hit.add(modules[alias.name])
|
|
355
|
+
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
356
|
+
for name, spellings in wanted.items():
|
|
357
|
+
if node.value in spellings:
|
|
358
|
+
hit.add(name)
|
|
359
|
+
return hit
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def pr_reachable_scripts(root: Path, workflow_docs: dict[str, dict]) -> set[str]:
|
|
363
|
+
"""Scripts a pull request actually executes, following indirect calls."""
|
|
364
|
+
scripts_dir = root / "scripts"
|
|
365
|
+
names = {p.name for p in scripts_dir.glob("*.py")}
|
|
366
|
+
# This file names five scripts in NOT_A_PR_GATE, and this file runs on every PR.
|
|
367
|
+
# Counting its own source as a caller made each declared script "reachable" and
|
|
368
|
+
# then reported the declaration as stale — the checker proving its own entries
|
|
369
|
+
# wrong. Declaring a script is not calling it.
|
|
370
|
+
sources = {
|
|
371
|
+
p.name: p.read_text(encoding="utf-8")
|
|
372
|
+
for p in scripts_dir.glob("*.py")
|
|
373
|
+
if p.name != Path(__file__).name
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
reachable: set[str] = set()
|
|
377
|
+
for path, doc in workflow_docs.items():
|
|
378
|
+
triggers = doc.get("on", doc.get(True))
|
|
379
|
+
keys = set(triggers) if isinstance(triggers, (dict, list)) else set()
|
|
380
|
+
if "pull_request" not in keys:
|
|
381
|
+
continue
|
|
382
|
+
reachable |= {name for name in names if name in (root / path).read_text(encoding="utf-8")}
|
|
383
|
+
|
|
384
|
+
# Fixpoint: a script the PR runs may load or import others.
|
|
385
|
+
while True:
|
|
386
|
+
grown = set(reachable)
|
|
387
|
+
for name in list(reachable):
|
|
388
|
+
grown |= _script_references(sources.get(name, ""), names)
|
|
389
|
+
if grown == reachable:
|
|
390
|
+
return reachable
|
|
391
|
+
reachable = grown
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def check_every_gate_runs_on_a_pr(root: Path, workflow_docs: dict[str, dict]) -> list[str]:
|
|
395
|
+
"""An entry script must run on a pull request, or say why it does not."""
|
|
396
|
+
scripts_dir = root / "scripts"
|
|
397
|
+
if not scripts_dir.is_dir():
|
|
398
|
+
return []
|
|
399
|
+
entries = {
|
|
400
|
+
p.name
|
|
401
|
+
for p in scripts_dir.glob("*.py")
|
|
402
|
+
if "def main(" in p.read_text(encoding="utf-8")
|
|
403
|
+
}
|
|
404
|
+
reachable = pr_reachable_scripts(root, workflow_docs)
|
|
405
|
+
|
|
406
|
+
problems = [
|
|
407
|
+
f"scripts/{name} runs nowhere on a pull request and is not in NOT_A_PR_GATE "
|
|
408
|
+
"— a gate whose first real execution is the release has guarded nothing"
|
|
409
|
+
for name in sorted(entries - reachable)
|
|
410
|
+
if name not in NOT_A_PR_GATE
|
|
411
|
+
]
|
|
412
|
+
problems += [
|
|
413
|
+
f"NOT_A_PR_GATE declares {name!r}, but no such script exists — stale entry"
|
|
414
|
+
for name in sorted(NOT_A_PR_GATE)
|
|
415
|
+
if name not in entries
|
|
416
|
+
]
|
|
417
|
+
problems += [
|
|
418
|
+
f"NOT_A_PR_GATE declares {name!r}, but a pull request does run it now "
|
|
419
|
+
"— drop the entry rather than leave a false caveat standing"
|
|
420
|
+
for name in sorted(NOT_A_PR_GATE)
|
|
421
|
+
if name in reachable
|
|
422
|
+
]
|
|
423
|
+
return problems
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def npm_test_scripts(root: Path) -> set[str]:
|
|
427
|
+
"""The scripts the documented pre-push command actually runs."""
|
|
428
|
+
package = root / "package.json"
|
|
429
|
+
if not package.exists():
|
|
430
|
+
return set()
|
|
431
|
+
data = json.loads(package.read_text(encoding="utf-8"))
|
|
432
|
+
command = str((data.get("scripts") or {}).get("test") or "")
|
|
433
|
+
return set(re.findall(r"scripts/([a-z0-9_-]+\.py)", command))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def pr_workflow_scripts(root: Path, workflow_docs: dict[str, dict]) -> set[str]:
|
|
437
|
+
"""Scripts named outright by a workflow that triggers on `pull_request`.
|
|
438
|
+
|
|
439
|
+
Direct mentions only, unlike `pr_reachable_scripts`: `npm test` runs commands, so
|
|
440
|
+
what it has to match is the commands CI runs, not everything those import.
|
|
441
|
+
"""
|
|
442
|
+
named: set[str] = set()
|
|
443
|
+
for path, doc in workflow_docs.items():
|
|
444
|
+
triggers = doc.get("on", doc.get(True))
|
|
445
|
+
keys = set(triggers) if isinstance(triggers, (dict, list)) else set()
|
|
446
|
+
if "pull_request" not in keys:
|
|
447
|
+
continue
|
|
448
|
+
named |= set(re.findall(r"scripts/([a-z0-9_-]+\.py)", (root / path).read_text(encoding="utf-8")))
|
|
449
|
+
return named
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def check_npm_test_covers_pr_gates(root: Path, workflow_docs: dict[str, dict]) -> list[str]:
|
|
453
|
+
"""What CI runs on a PR, `npm test` must run too — or say why it does not."""
|
|
454
|
+
if not (root / "package.json").exists():
|
|
455
|
+
return []
|
|
456
|
+
in_ci = pr_workflow_scripts(root, workflow_docs)
|
|
457
|
+
in_npm = npm_test_scripts(root)
|
|
458
|
+
|
|
459
|
+
problems = [
|
|
460
|
+
f"scripts/{name} runs on every PR in CI but is not in `npm test` and not in "
|
|
461
|
+
"NOT_IN_NPM_TEST — the command that exists to pre-empt CI does not cover it"
|
|
462
|
+
for name in sorted(in_ci - in_npm)
|
|
463
|
+
if name not in NOT_IN_NPM_TEST
|
|
464
|
+
]
|
|
465
|
+
problems += [
|
|
466
|
+
f"NOT_IN_NPM_TEST declares {name!r}, but `npm test` runs it now — drop the "
|
|
467
|
+
"entry rather than leave a false caveat standing"
|
|
468
|
+
for name in sorted(NOT_IN_NPM_TEST)
|
|
469
|
+
if name in in_npm
|
|
470
|
+
]
|
|
471
|
+
problems += [
|
|
472
|
+
f"NOT_IN_NPM_TEST declares {name!r}, but no PR workflow runs it — stale entry"
|
|
473
|
+
for name in sorted(NOT_IN_NPM_TEST)
|
|
474
|
+
if name not in in_ci
|
|
475
|
+
]
|
|
476
|
+
return problems
|
|
477
|
+
|
|
478
|
+
|
|
254
479
|
def discover_test_files(root: Path) -> list[str]:
|
|
255
480
|
return sorted(
|
|
256
481
|
str(p.relative_to(root))
|
|
@@ -356,6 +581,8 @@ def run_all(root: Path, fidelity_report: Path | None = None) -> list[str]:
|
|
|
356
581
|
workflows = read_workflows(root)
|
|
357
582
|
problems += check_advisory_gates_declared(workflows)
|
|
358
583
|
problems += check_declared_gates_still_exist(workflows)
|
|
584
|
+
problems += check_every_gate_runs_on_a_pr(root, workflows)
|
|
585
|
+
problems += check_npm_test_covers_pr_gates(root, workflows)
|
|
359
586
|
|
|
360
587
|
# check_graded_suites_graded_something shipped fully written and unit-tested
|
|
361
588
|
# but unreferenced by run_all — the anti-fake-green script had a check that
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Gate: every line a persona presents as a quotation must name where it is from.
|
|
3
|
+
|
|
4
|
+
Steps 3h and 3i of the weekly check ask whether a quoted line exists — in CBETA,
|
|
5
|
+
or in the compiled teachings CBETA does not hold. Neither asks whether the reader
|
|
6
|
+
is told *which book* it came from, and that gap is where a misattribution
|
|
7
|
+
survives both: master-nagarjuna's 「宁起我见积若须弥」 is real text, findable in
|
|
8
|
+
CBETA, and not his — it is in 《大宝积经》. "Found in the canon" and "correctly
|
|
9
|
+
attributed" are different questions, and only the first was being asked.
|
|
10
|
+
|
|
11
|
+
This one is offline, so it runs on every PR instead of once a week.
|
|
12
|
+
|
|
13
|
+
Attribution counts when the source is named:
|
|
14
|
+
|
|
15
|
+
- on the quoted line itself — 《书名》, 【…】, `Toh 3861`, `SC: SN 22.59`, 卷N;
|
|
16
|
+
- on a `> 出处:…` line a few lines below (the excerpt files put it there);
|
|
17
|
+
- on a sibling item of the same numbered list (one 出处 covers the block, which
|
|
18
|
+
is how master-fazang's and master-kumarajiva's voice samples are written);
|
|
19
|
+
- in the nearest section heading above (master-ajahn-chah's sutta excerpts name
|
|
20
|
+
the sutta in the `###` heading).
|
|
21
|
+
|
|
22
|
+
It reuses `tools/verify_sources.collect_persona_quotes`, so the set it checks is
|
|
23
|
+
exactly the set 3h and 3i judge — if the collector learns a new quotation shape,
|
|
24
|
+
this gate covers it the same day.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
_TOOLS = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "tools")
|
|
34
|
+
if _TOOLS not in sys.path:
|
|
35
|
+
sys.path.insert(0, _TOOLS)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from verify_sources import PREBUILT_DIR, collect_persona_quotes
|
|
39
|
+
except ImportError as exc: # pragma: no cover - the gate must not pass silently
|
|
40
|
+
print(f"FAIL: cannot import the quote collector from tools/ ({exc}).")
|
|
41
|
+
print("A gate that cannot load what it checks has not checked anything.")
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
# 什么算「写明了出处」。SC:/Toh/PTS 是南传与藏传的写法,卷N 与《…》是汉传的。
|
|
45
|
+
_SOURCE_NAMED = re.compile(
|
|
46
|
+
r"《[^》]{1,40}》|【|Toh[:\s]\d|SC[::]|\b[SMAD]N \d|Vism|PTS|卷[一二三四五六七八九十百千\d]|[Ss]utta"
|
|
47
|
+
)
|
|
48
|
+
_NUMBERED_ITEM = re.compile(r"^\s*\d+\.\s")
|
|
49
|
+
_LOOK = 8
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def attribution(lines: list[str], number: int) -> str | None:
|
|
53
|
+
"""这条引文的出处写在哪儿;哪儿都没写就返回 None。"""
|
|
54
|
+
i = number - 1
|
|
55
|
+
if i < 0 or i >= len(lines):
|
|
56
|
+
return None
|
|
57
|
+
if _SOURCE_NAMED.search(lines[i]):
|
|
58
|
+
return "on the line"
|
|
59
|
+
|
|
60
|
+
# 摘录文件把「出处」写在引文下方几行。
|
|
61
|
+
for j in range(i + 1, min(len(lines), i + 1 + _LOOK)):
|
|
62
|
+
if "出处" in lines[j]:
|
|
63
|
+
return f"出处 line {j - i} below" if _SOURCE_NAMED.search(lines[j]) else None
|
|
64
|
+
|
|
65
|
+
# 编号示例句:整块共用一个出处,挂在其中一条上。
|
|
66
|
+
if _NUMBERED_ITEM.match(lines[i]):
|
|
67
|
+
low = i
|
|
68
|
+
while low - 1 >= 0 and _NUMBERED_ITEM.match(lines[low - 1]):
|
|
69
|
+
low -= 1
|
|
70
|
+
high = i
|
|
71
|
+
while high + 1 < len(lines) and _NUMBERED_ITEM.match(lines[high + 1]):
|
|
72
|
+
high += 1
|
|
73
|
+
for j in range(low, high + 1):
|
|
74
|
+
if j != i and _SOURCE_NAMED.search(lines[j]):
|
|
75
|
+
return f"sibling list item {j + 1 - low}"
|
|
76
|
+
|
|
77
|
+
# 最近的小节标题。
|
|
78
|
+
for j in range(i - 1, max(-1, i - 1 - _LOOK), -1):
|
|
79
|
+
if lines[j].startswith("#"):
|
|
80
|
+
return f"heading {i - j} above" if _SOURCE_NAMED.search(lines[j]) else None
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def unattributed(quotes: list[tuple[str, str, str]], read) -> list[tuple[str, str]]:
|
|
85
|
+
"""[(位置, 引文)]:读者无从知道出自哪部书的引文。"""
|
|
86
|
+
cache: dict[str, list[str]] = {}
|
|
87
|
+
missing: list[tuple[str, str]] = []
|
|
88
|
+
for where, _master, quote in quotes:
|
|
89
|
+
rel, _, number = where.rpartition(":")
|
|
90
|
+
if not number.isdigit():
|
|
91
|
+
continue # meta.json 的 lore_triggers 之类,不是文档行
|
|
92
|
+
if rel not in cache:
|
|
93
|
+
cache[rel] = read(rel)
|
|
94
|
+
if attribution(cache[rel], int(number)) is None:
|
|
95
|
+
missing.append((where, quote))
|
|
96
|
+
return missing
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> int:
|
|
100
|
+
def read(rel: str) -> list[str]:
|
|
101
|
+
return Path(PREBUILT_DIR, rel).read_text(encoding="utf-8").splitlines()
|
|
102
|
+
|
|
103
|
+
quotes = collect_persona_quotes()
|
|
104
|
+
if not quotes:
|
|
105
|
+
print("FAIL: the quote collector returned nothing — this gate examined an empty set.")
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
missing = unattributed(quotes, read)
|
|
109
|
+
if missing:
|
|
110
|
+
print(f"FAIL: {len(missing)} quoted line(s) name no source:")
|
|
111
|
+
for where, quote in missing:
|
|
112
|
+
print(f" {where}\n 「{quote[:56]}」")
|
|
113
|
+
print(
|
|
114
|
+
"\nA reader cannot check a quotation whose book is never named, and a line\n"
|
|
115
|
+
"that happens to exist somewhere in the canon passes the weekly checks while\n"
|
|
116
|
+
"still being the wrong master's words. Name the work on the line, in the\n"
|
|
117
|
+
"`> 出处:` line below it, on a sibling numbered item, or in the heading above."
|
|
118
|
+
)
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
print(f"OK: all {len(quotes)} quoted lines name the work they come from.")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
sys.exit(main())
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "CBETA 不收、但有免费全文可取的编集语录。周检 3i 用它把人设里当原话引的句子拿到原书里逐字找。3h 只能查 CBETA,查不到时对这些祖师一律记『未判定』——2026-09-15 修掉的那批拼接引文(虚云、印光)正是长在这个盲区里。coverage 决定这一步能不能判错:complete 表示该祖师声明的编集语录全都在下面取得到,找不到即伪造;partial 表示还有声明了却取不到全文的,找不到只记未判定,绝不报错。宁可少判,不可错判。",
|
|
3
|
+
"corpora": [
|
|
4
|
+
{
|
|
5
|
+
"master": "master-yinguang",
|
|
6
|
+
"corpus_title": "《印光法师文钞》",
|
|
7
|
+
"coverage": "complete",
|
|
8
|
+
"coverage_reason": "meta.json 声明的编集语录是正编、续编、三编,外加一个只作总名解析用的『Yinguang:Wenchao』(正续三编的合称,非另一部书)。这三编就是《文钞》的全部,且都在殆知阁语料库里。2026-09-16 核验:三个文件在 commit e9e11f1 下分别为 1285781 / 1410227 / 1973347 字节,且 voice.md 现有三条引文逐字命中(正编 2 条、续编 1 条)。故查不到即可判错。",
|
|
9
|
+
"verified_on": "2026-09-16",
|
|
10
|
+
"not_a_separate_book": [
|
|
11
|
+
"Yinguang:Wenchao"
|
|
12
|
+
],
|
|
13
|
+
"not_a_separate_book_reason": "『Yinguang:Wenchao』是正编、续编、三编的合称,meta.json 里留着它只为解析只写总名的引用,并非第四部书。列在这里,coverage=complete 才是可核验的声明而不是一句断言——测试会要求每个声明过的编集来源要么有全文、要么在这里说明为什么不需要。",
|
|
14
|
+
"texts": [
|
|
15
|
+
{
|
|
16
|
+
"id": "Yinguang:WenchaoZhengbian",
|
|
17
|
+
"title": "印光法师文钞正编",
|
|
18
|
+
"url": "https://raw.githubusercontent.com/daizhige-org/daizhigev20/e9e11f19b7e6bd9c1284bbdab71d2a9cb94d637c/佛藏/藏外/印光法师文钞.md",
|
|
19
|
+
"encoding": "utf-8"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "Yinguang:WenchaoXubian",
|
|
23
|
+
"title": "印光法师文钞续编",
|
|
24
|
+
"url": "https://raw.githubusercontent.com/daizhige-org/daizhigev20/e9e11f19b7e6bd9c1284bbdab71d2a9cb94d637c/佛藏/藏外/印光法师文钞续编.md",
|
|
25
|
+
"encoding": "utf-8"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "Yinguang:WenchaoSanbian",
|
|
29
|
+
"title": "印光法师文钞三编",
|
|
30
|
+
"url": "https://raw.githubusercontent.com/daizhige-org/daizhigev20/e9e11f19b7e6bd9c1284bbdab71d2a9cb94d637c/佛藏/藏外/印光法师文钞三编.md",
|
|
31
|
+
"encoding": "utf-8"
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"source_note": "殆知阁古代文献 v2.0(github.com/daizhige-org/daizhigev20),默认分支是 data 不是 master——用 master 取会 404,2026-09-16 就这样踩过一次。地址钉在 commit e9e11f1(2026-09-12)上,分支移动不会悄悄换掉被核对的底本;哪天该 commit 取不到,3i 记未判定而不是判错。url 一律写字面字符、不要预先百分号编码:编码由 fetch_compiled_text 统一做,清单里再编一次就成了 %25 开头的双重编码,一取就 404 —— 2026-09-16 首次实跑正是这样让印光三部全部『取不到』的。"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"master": "master-xuyun",
|
|
38
|
+
"corpus_title": "《虚云和尚法汇》《虚云老和尚年谱》",
|
|
39
|
+
"coverage": "partial",
|
|
40
|
+
"coverage_reason": "meta.json 声明了开示录、法汇、年谱三种,BFNN 上只有岑学吕编的法汇六部与年谱,没有《虚云老和尚开示录》——后者是净慧所编,《虚云和尚全集》相对岑本新增约六十余万字,仅开示就多出 110 余则。也就是说虚云的真引文完全可能出自这里取不到的那部分,所以这部语料只能用来确认,不能用来判错。哪天开示录有了可取的免费全文,再改成 complete。",
|
|
41
|
+
"verified_on": "2026-09-16",
|
|
42
|
+
"texts": [
|
|
43
|
+
{
|
|
44
|
+
"id": "Xuyun:Nianpu",
|
|
45
|
+
"title": "虚云和尚年谱",
|
|
46
|
+
"url": "http://bookgb.bfnn.org/books2/1184.htm",
|
|
47
|
+
"encoding": "gb18030"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "Xuyun:Fahui",
|
|
51
|
+
"title": "虚云和尚法汇—法语",
|
|
52
|
+
"url": "http://bookgb.bfnn.org/books2/1185.htm",
|
|
53
|
+
"encoding": "gb18030"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "Xuyun:Fahui",
|
|
57
|
+
"title": "虚云和尚法汇—开示",
|
|
58
|
+
"url": "http://bookgb.bfnn.org/books2/1186.htm",
|
|
59
|
+
"encoding": "gb18030"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "Xuyun:Fahui",
|
|
63
|
+
"title": "虚云和尚法汇—书问",
|
|
64
|
+
"url": "http://bookgb.bfnn.org/books2/1187.htm",
|
|
65
|
+
"encoding": "gb18030"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "Xuyun:Fahui",
|
|
69
|
+
"title": "虚云和尚法汇—文记",
|
|
70
|
+
"url": "http://bookgb.bfnn.org/books2/1188.htm",
|
|
71
|
+
"encoding": "gb18030"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "Xuyun:Fahui",
|
|
75
|
+
"title": "虚云和尚法汇—规约",
|
|
76
|
+
"url": "http://bookgb.bfnn.org/books2/1189.htm",
|
|
77
|
+
"encoding": "gb18030"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"id": "Xuyun:Fahui",
|
|
81
|
+
"title": "虚云和尚法汇—诗歌偈赞",
|
|
82
|
+
"url": "http://bookgb.bfnn.org/books2/1190.htm",
|
|
83
|
+
"encoding": "gb18030"
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"source_note": "BFNN(bookgb.bfnn.org)转录岑学吕编本,gb18030 编码,默认 Python-urllib 即可取(不像 FoJin 会挡 UA)。2026-09-16 核验:voice.md 现有三条引文逐字命中 1186《法汇—开示》。"
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
@@ -66,6 +66,17 @@
|
|
|
66
66
|
"reason": "大藏经补编(B)与《佛教大藏经》(G)所收的法尊译本不在 FoJin 收录范围内。2026-09-15 以 tools/fojin_bridge.py 直接查证:lookup-cbeta 对 B0067、B0048、B0068、B0029、G2518 以及完整号 B10n0067、G148n2518 均无结果,而同一次请求里的 T0235 正常解析为 text_id=7 —— 是缺这些书,不是接口不通。",
|
|
67
67
|
"verified_absent_on": "2026-09-15",
|
|
68
68
|
"consequence": "master-atisha 引用《菩提道灯论》时拿不到 fojin.app 活链接。离线审计不受影响;周检 3f 的原文取自 CBETA API,照常核对「原典」块。"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"cbeta_id": "B11n0073",
|
|
72
|
+
"short_cbeta_id": "B0073",
|
|
73
|
+
"title": "木纳记(尊者传汉译)",
|
|
74
|
+
"used_by": [
|
|
75
|
+
"master-milarepa"
|
|
76
|
+
],
|
|
77
|
+
"reason": "大藏经补编(B)不在 FoJin 收录范围内。2026-09-16 以 tools/fojin_bridge.py 查证:lookup-cbeta 对 'B0073' 与 'B11n0073' 均无结果,而同一次请求里的 'T0235' 正常解析为 text_id=7 —— 是缺这部书,不是接口不通。",
|
|
78
|
+
"verified_absent_on": "2026-09-16",
|
|
79
|
+
"consequence": "master-milarepa 引用《木纳记》时拿不到 fojin.app 活链接。离线审计不受影响;周检 3f 的原文取自 CBETA API,照常核对引文。"
|
|
69
80
|
}
|
|
70
81
|
]
|
|
71
82
|
}
|
package/tools/verify_sources.py
CHANGED
|
@@ -673,6 +673,80 @@ def collect_excerpt_quotes() -> list[tuple[str, str, str, int | None]]:
|
|
|
673
673
|
return quotes
|
|
674
674
|
|
|
675
675
|
|
|
676
|
+
def collect_compiled_excerpt_blocks() -> list[tuple[str, str, str]]:
|
|
677
|
+
"""(位置, 引文, 所引篇名):「原典」块中引用格式指向 CBETA 之外编集语录的那些。
|
|
678
|
+
|
|
679
|
+
`collect_excerpt_quotes` 只收引用格式带 CBETA 经号的块,而《文钞》没有经号,
|
|
680
|
+
于是 master-yinguang 的五个「原典」块对 3f 不可见;它们的 `>` 行又是裸行文、
|
|
681
|
+
不带引号,`collect_persona_quotes` 同样收不到。2026-09-16 核出其中两块是用
|
|
682
|
+
真语拼接的改写,却一直以「原典」示人 —— 没有任何一步检查看得见它们。
|
|
683
|
+
"""
|
|
684
|
+
base = Path(PREBUILT_DIR)
|
|
685
|
+
blocks: list[tuple[str, str, str]] = []
|
|
686
|
+
for path in sorted(base.glob("*/sources/*-excerpts.md")):
|
|
687
|
+
where = path.relative_to(base).as_posix()
|
|
688
|
+
label_line, lines = 0, []
|
|
689
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
690
|
+
if line.startswith("原典"):
|
|
691
|
+
label_line, lines = number, []
|
|
692
|
+
elif not label_line:
|
|
693
|
+
continue
|
|
694
|
+
elif line.startswith(">"):
|
|
695
|
+
lines.append(line[1:].strip())
|
|
696
|
+
elif line.startswith("#"):
|
|
697
|
+
label_line, lines = 0, []
|
|
698
|
+
elif "引用格式" in line:
|
|
699
|
+
citation = _DOC_CITATION.search(line)
|
|
700
|
+
text = citation.group(1) if citation else ""
|
|
701
|
+
if text and not _DOC_CBETA_ID.search(text) and any(lines):
|
|
702
|
+
blocks.append((f"{where}:{label_line}", "\n".join(lines), text))
|
|
703
|
+
label_line, lines = 0, []
|
|
704
|
+
return blocks
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def classify_compiled_excerpt_blocks(
|
|
708
|
+
blocks: list[tuple[str, str, str]],
|
|
709
|
+
corpora: dict[str, dict],
|
|
710
|
+
fetch,
|
|
711
|
+
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str]], list[tuple[str, str]]]:
|
|
712
|
+
"""「原典」块是不是所引编集语录的原文;省略号分段,每段都要在原书里找得到。
|
|
713
|
+
|
|
714
|
+
与 3i 判引文行同理:原书取不到一律记未判定,接口不通不是证据。语料不全
|
|
715
|
+
(coverage=partial)也只能确认、不能定罪 —— 判「原书没有」需要读得到全部。
|
|
716
|
+
"""
|
|
717
|
+
mismatched: list[tuple[str, str, str]] = []
|
|
718
|
+
verified: list[tuple[str, str]] = []
|
|
719
|
+
unknown: list[tuple[str, str]] = []
|
|
720
|
+
bodies: dict[str, str | None] = {}
|
|
721
|
+
for where, quote, citation in blocks:
|
|
722
|
+
master = where.split("/", 1)[0]
|
|
723
|
+
corpus = corpora.get(master)
|
|
724
|
+
if not corpus:
|
|
725
|
+
unknown.append((where, f"{master} declares no fetchable corpus"))
|
|
726
|
+
continue
|
|
727
|
+
texts = corpus.get("texts") or []
|
|
728
|
+
for text in texts:
|
|
729
|
+
url = str(text.get("url"))
|
|
730
|
+
if url not in bodies:
|
|
731
|
+
bodies[url] = fetch(url, text.get("encoding") or "utf-8")
|
|
732
|
+
readable = [bodies.get(str(t.get("url"))) for t in texts]
|
|
733
|
+
if not any(body for body in readable):
|
|
734
|
+
unknown.append((where, "could not read the declared full texts"))
|
|
735
|
+
continue
|
|
736
|
+
segments = [s for s in re.split(r"…+", quote) if len(_han_only(s)) >= EXCERPT_MIN_CLAUSE * 2]
|
|
737
|
+
if not segments:
|
|
738
|
+
unknown.append((where, "no segment long enough to search"))
|
|
739
|
+
continue
|
|
740
|
+
absent = [s for s in segments if not any(b and _han_only(s) in b for b in readable)]
|
|
741
|
+
if not absent:
|
|
742
|
+
verified.append((where, citation))
|
|
743
|
+
elif corpus.get("coverage") == "complete":
|
|
744
|
+
mismatched.append((where, absent[0].strip(), citation))
|
|
745
|
+
else:
|
|
746
|
+
unknown.append((where, f"{master}'s free full texts do not cover every declared compilation"))
|
|
747
|
+
return mismatched, verified, unknown
|
|
748
|
+
|
|
749
|
+
|
|
676
750
|
def cbeta_juan_plain_text(html: str) -> str:
|
|
677
751
|
"""`/stable/juans` 返回的 HTML → 正文。
|
|
678
752
|
|
|
@@ -955,6 +1029,281 @@ def classify_bdrc_records(
|
|
|
955
1029
|
return mismatched, unknown
|
|
956
1030
|
|
|
957
1031
|
|
|
1032
|
+
CBETA_SEARCH_URL = "https://cbdata.dila.edu.tw/stable/search"
|
|
1033
|
+
|
|
1034
|
+
# 「当原话呈现」的三种写法:voice.md 的编号示例句、teaching.md 的引用块、
|
|
1035
|
+
# 行内带书名号的「云/曰」。模板句(含「……」或「/」选项)、统一拒答话术、
|
|
1036
|
+
# 以及人设自己标了「转述/非原文/主旨」的行都不是引文,不收。
|
|
1037
|
+
_QUOTE_SAMPLE = re.compile(r'^\s*\d+\.\s*[“"「『]([^”"」』\n]{8,200})')
|
|
1038
|
+
_QUOTE_BLOCK = re.compile(r'^\s*>\s*[“"「『]([^”"」』\n]{8,200})')
|
|
1039
|
+
_QUOTE_SAID = re.compile(r'(?:云|曰|偈云|经云|论云)\s*[::]?\s*[“"「『]([^”"」』\n]{8,200})')
|
|
1040
|
+
# 具名引出 + **冒号**:「佛说:""」「神秀偈:""」「慧能曰:""」「达摩祖师偈:""」。
|
|
1041
|
+
# 冒号是把「引原典」与「人设自己的话」分开的判别式 —— 后者写作「常说"看看那个想要
|
|
1042
|
+
# 解决问题的心"」「先问"为什么想读?"」,一律没有冒号。2026-09-16 量过:这一条能收进
|
|
1043
|
+
# 慧能的风幡、神秀与达摩的偈、阿姜查所引三段巴利经文,而不碰任何一句话术示例。
|
|
1044
|
+
_QUOTE_ATTRIBUTED = re.compile(
|
|
1045
|
+
r"(?:佛|世尊|[㐀-鿿]{2,6}(?:祖师|大师|尊者|菩萨|长老|禅师|居士)?)"
|
|
1046
|
+
r'\s*(?:偈曰|偈云|偈|曰|说)\s*[::]\s*[“"「『]([^”"」』\n]{8,200})'
|
|
1047
|
+
)
|
|
1048
|
+
# 《书名》同行引文,不需要动词:「《金刚经》"一切有为法…"」「闻《金刚经》至"应无所住
|
|
1049
|
+
# 而生其心"」。原先只认「云/曰」,这类引文一条都进不来。
|
|
1050
|
+
_QUOTE_TITLED = re.compile(r'《[^》\n]{2,30}》[^“"「『\n]{0,10}[“"「『]([^”"」』\n]{8,200})')
|
|
1051
|
+
_QUOTE_BOILER = re.compile(
|
|
1052
|
+
r"具格上师|亲近善知识|不可由文字|网络传授|须依止|本平台|不得对个体|面对面访谈"
|
|
1053
|
+
r"|如需深入学习|SuttaCentral|BDRC|fojin"
|
|
1054
|
+
# 书单与指引句:「汉译可参《菩提道灯论》(任杰译)」「《清净道论》汉译:叶均居士
|
|
1055
|
+
# 译本」「…可在 ajahnchah.org 免费下载」。它们写在引号里,却不是谁说过的话,
|
|
1056
|
+
# 送去全文检索只会变成查无此句。2026-09-16 量出 22 条这样的行。
|
|
1057
|
+
r"|可参|可详参|可阅|可查|查阅|译本|出版社|下载|开示全集|不可不读|逐句观照"
|
|
1058
|
+
)
|
|
1059
|
+
_QUOTE_PARAPHRASE = re.compile(r"转述|非原文|主旨|整理|概括|要旨|讲解")
|
|
1060
|
+
# 讲「这句话该不该引、该怎么标」的行,本身不是引文:纠错说明(「常被当作龙树的话
|
|
1061
|
+
# 引用,但《大智度论》中没有」)、禁用示例(「不可用宗喀巴的精确分判作为阿底峡立场」)。
|
|
1062
|
+
# 收了它们,周检会对一条文档已经查明并改正的记录拉响假警报。
|
|
1063
|
+
# 标记必须是关于**引用行为**的成句短语:试过「勿」「不可用」这类泛词,会误伤《坛经》
|
|
1064
|
+
# 「勿使惹尘埃」、罗什「且勿急」、虚云「不可用意识思量卜度」这些真引文(2026-09-16 实测)。
|
|
1065
|
+
# 「中没有」同样要紧跟书名号,否则撞上「心中没有」之类的寻常行文。
|
|
1066
|
+
_QUOTE_META = re.compile(
|
|
1067
|
+
r"常被当作|误传|讹传|应保守表述|不要把|不得加引号|引用规范|disclaimer|》中没有|》中查无"
|
|
1068
|
+
)
|
|
1069
|
+
_QUOTE_HAN = re.compile(r"[\u3400-\u9fff]")
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def collect_persona_quotes() -> list[tuple[str, str, str]]:
|
|
1073
|
+
"""(位置, 祖师目录, 引文):人设 references / sources 里当原话呈现的句子。
|
|
1074
|
+
|
|
1075
|
+
3f 只看摘录里的「原典」块,而编造的语录恰恰长在别处 —— 2026-09-15 一次手工
|
|
1076
|
+
核查在 voice.md 的「示例句」里查出玄奘「因明立量,非为诤胜」、智顗「功在渐次,
|
|
1077
|
+
证在圆融」等五条查无出处,还有三条是灌顶、澄观、彭际清的话挂在祖师名下。
|
|
1078
|
+
"""
|
|
1079
|
+
base = Path(PREBUILT_DIR)
|
|
1080
|
+
quotes: list[tuple[str, str, str]] = []
|
|
1081
|
+
for meta_path in sorted(base.glob("*/meta.json")):
|
|
1082
|
+
master = meta_path.parent.name
|
|
1083
|
+
for path in sorted((meta_path.parent / "references").glob("*.md")) + sorted(
|
|
1084
|
+
(meta_path.parent / "sources").glob("*.md")
|
|
1085
|
+
):
|
|
1086
|
+
where_base = path.relative_to(base).as_posix()
|
|
1087
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
1088
|
+
if (
|
|
1089
|
+
"出处" in line
|
|
1090
|
+
or "引用格式" in line
|
|
1091
|
+
or _QUOTE_PARAPHRASE.search(line)
|
|
1092
|
+
or _QUOTE_META.search(line)
|
|
1093
|
+
):
|
|
1094
|
+
continue
|
|
1095
|
+
for pattern, needs_title in (
|
|
1096
|
+
(_QUOTE_SAMPLE, False),
|
|
1097
|
+
(_QUOTE_BLOCK, False),
|
|
1098
|
+
(_QUOTE_SAID, True),
|
|
1099
|
+
(_QUOTE_ATTRIBUTED, False),
|
|
1100
|
+
(_QUOTE_TITLED, False),
|
|
1101
|
+
):
|
|
1102
|
+
match = pattern.search(line)
|
|
1103
|
+
if not match:
|
|
1104
|
+
continue
|
|
1105
|
+
if needs_title and "《" not in line:
|
|
1106
|
+
break
|
|
1107
|
+
quote = match.group(1).split("——", 1)[0]
|
|
1108
|
+
if (
|
|
1109
|
+
len(_QUOTE_HAN.findall(quote)) >= 8
|
|
1110
|
+
and "……" not in quote
|
|
1111
|
+
and "/" not in quote
|
|
1112
|
+
and not _QUOTE_BOILER.search(quote)
|
|
1113
|
+
):
|
|
1114
|
+
quotes.append((f"{where_base}:{number}", master, quote))
|
|
1115
|
+
break
|
|
1116
|
+
return quotes
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def persona_source_families() -> dict[str, set[str]]:
|
|
1120
|
+
"""{祖师目录: 声明来源的家族集合}。只声明 CBETA 的人设,引文必须在 CBETA 里。"""
|
|
1121
|
+
families: dict[str, set[str]] = {}
|
|
1122
|
+
for meta_path in sorted(Path(PREBUILT_DIR).glob("*/meta.json")):
|
|
1123
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
1124
|
+
kinds = {str(src.get("type")) for src in meta.get("sources") or [] if src.get("type")}
|
|
1125
|
+
if kinds:
|
|
1126
|
+
families[meta_path.parent.name] = kinds
|
|
1127
|
+
return families
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def declared_cbeta_works() -> dict[str, list[str]]:
|
|
1131
|
+
"""{祖师目录: [CBETA API 的 work 参数]},取自各 meta.json 声明的 cbeta 来源。"""
|
|
1132
|
+
works: dict[str, list[str]] = {}
|
|
1133
|
+
for meta_path in sorted(Path(PREBUILT_DIR).glob("*/meta.json")):
|
|
1134
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
1135
|
+
found = {
|
|
1136
|
+
_cbeta_api_work(str(src.get("id")))
|
|
1137
|
+
for src in meta.get("sources") or []
|
|
1138
|
+
if src.get("type") == "cbeta" and _cbeta_api_work(str(src.get("id")))
|
|
1139
|
+
}
|
|
1140
|
+
if found:
|
|
1141
|
+
works[meta_path.parent.name] = sorted(found)
|
|
1142
|
+
return works
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
# CBETA 全文检索只认繁体:简体「应无所住而生其心」查 0 条,繁体 343 条(2026-09-16
|
|
1146
|
+
# 实测,另试过 lang/variants/simplified 等七种参数,都不会放宽)。opencc 的 `s2t`
|
|
1147
|
+
# 会出「爲」「衆」这类异体,CBETA 用「為」「眾」,照样查不到,所以用 `s2tw` 再补一层
|
|
1148
|
+
# 归一 —— 少了这一层,《坛经》《中论》的真引文都会被报成查无此句。
|
|
1149
|
+
_TRADITIONAL_FIX = str.maketrans({"爲": "為", "衆": "眾", "眞": "真", "僞": "偽"})
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def to_traditional(text: str) -> str:
|
|
1153
|
+
import opencc
|
|
1154
|
+
|
|
1155
|
+
for config in ("s2tw", "s2tw.json"):
|
|
1156
|
+
try:
|
|
1157
|
+
return opencc.OpenCC(config).convert(text).translate(_TRADITIONAL_FIX)
|
|
1158
|
+
except Exception: # noqa: BLE001 — 配置名在不同发行包里写法不同
|
|
1159
|
+
continue
|
|
1160
|
+
raise RuntimeError("opencc has no s2tw config")
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def cbeta_search_hits(clause: str, work: str | None = None) -> int | None:
|
|
1164
|
+
"""CBETA 全文检索命中数;`work` 限定在一部书里。接口出错返回 None(未知)。"""
|
|
1165
|
+
import urllib.error
|
|
1166
|
+
import urllib.request
|
|
1167
|
+
|
|
1168
|
+
params = {"q": clause, "rows": 1}
|
|
1169
|
+
if work:
|
|
1170
|
+
params["work"] = work
|
|
1171
|
+
url = f"{CBETA_SEARCH_URL}?{urllib.parse.urlencode(params)}"
|
|
1172
|
+
try:
|
|
1173
|
+
with urllib.request.urlopen(url, timeout=30) as response:
|
|
1174
|
+
return json.loads(response.read().decode("utf-8")).get("num_found") or 0
|
|
1175
|
+
except (urllib.error.URLError, OSError, ValueError, TypeError):
|
|
1176
|
+
return None
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def classify_persona_quotes(
|
|
1180
|
+
quotes: list[tuple[str, str, str]],
|
|
1181
|
+
families: dict[str, set[str]],
|
|
1182
|
+
works: dict[str, list[str]],
|
|
1183
|
+
search,
|
|
1184
|
+
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str]]]:
|
|
1185
|
+
"""把人设里当原话引的句子分成「CBETA 没有这句」与「比不了」两类。
|
|
1186
|
+
|
|
1187
|
+
判「没有」只对**声明来源全是 CBETA** 的人设成立。master-xuyun 的语录出自
|
|
1188
|
+
《虚云和尚法汇》、master-yinguang 的出自《文钞》,两部都不在 CBETA —— 对它们,
|
|
1189
|
+
查不到只说明这一步够不着,不是伪造。查得到却不在声明作品里的(玄奘引窥基所记
|
|
1190
|
+
的唯识比量、蕅益《要解》在净土十要本),同样记为未知:行文里往往已注明他书。
|
|
1191
|
+
"""
|
|
1192
|
+
mismatched: list[tuple[str, str, str]] = []
|
|
1193
|
+
unknown: list[tuple[str, str]] = []
|
|
1194
|
+
for where, master, quote in quotes:
|
|
1195
|
+
clauses = sorted((c for c in quote_clauses(quote) if len(c) >= 4), key=len, reverse=True)
|
|
1196
|
+
if not clauses:
|
|
1197
|
+
unknown.append((where, "no clause long enough to search"))
|
|
1198
|
+
continue
|
|
1199
|
+
clause = to_traditional(clauses[0])
|
|
1200
|
+
anywhere = search(clause, None)
|
|
1201
|
+
if anywhere is None:
|
|
1202
|
+
unknown.append((where, "CBETA did not answer"))
|
|
1203
|
+
elif not anywhere:
|
|
1204
|
+
if families.get(master) == {"cbeta"}:
|
|
1205
|
+
mismatched.append((where, quote, clause))
|
|
1206
|
+
else:
|
|
1207
|
+
unknown.append((where, f"{master} also declares non-CBETA sources"))
|
|
1208
|
+
elif not any(search(clause, work) for work in works.get(master, [])):
|
|
1209
|
+
unknown.append((where, "only in works this persona does not declare"))
|
|
1210
|
+
return mismatched, unknown
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
COMPILED_SOURCES_FILE = Path(__file__).parent / "compiled-teaching-sources.json"
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def compiled_teaching_corpora() -> dict[str, dict]:
|
|
1217
|
+
"""{祖师目录: 该祖师在 CBETA 之外、有免费全文可取的编集语录}。
|
|
1218
|
+
|
|
1219
|
+
3h 只能查 CBETA,所以虚云的《法汇》、印光的《文钞》一律落进「未判定」——
|
|
1220
|
+
2026-09-15 修掉的那批拼接引文正是长在这个盲区里。清单把原书地址登记下来,
|
|
1221
|
+
3i 就能真的进原书逐字找。
|
|
1222
|
+
"""
|
|
1223
|
+
if not COMPILED_SOURCES_FILE.exists():
|
|
1224
|
+
return {}
|
|
1225
|
+
data = json.loads(COMPILED_SOURCES_FILE.read_text(encoding="utf-8"))
|
|
1226
|
+
return {str(entry["master"]): entry for entry in data.get("corpora") or []}
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def _han_only(text: str) -> str:
|
|
1230
|
+
"""只留汉字。两边都这么归一,标点和空白的写法差异就不会造成假的对不上。"""
|
|
1231
|
+
return "".join(_QUOTE_HAN.findall(re.sub(r"<[^>]+>", "\n", text)))
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def fetch_compiled_text(url: str, encoding: str = "utf-8") -> str | None:
|
|
1235
|
+
"""取一部编集语录的全文,归一成纯汉字;取不到返回 None(未知,不是「原书没有」)。"""
|
|
1236
|
+
import urllib.error
|
|
1237
|
+
import urllib.parse
|
|
1238
|
+
import urllib.request
|
|
1239
|
+
|
|
1240
|
+
# urllib 不像 curl 会自己处理非 ASCII 路径,原样传中文路径会抛 UnicodeEncodeError。
|
|
1241
|
+
split = urllib.parse.urlsplit(url)
|
|
1242
|
+
safe = urllib.parse.urlunsplit(split._replace(path=urllib.parse.quote(split.path)))
|
|
1243
|
+
try:
|
|
1244
|
+
with urllib.request.urlopen(safe, timeout=90) as response:
|
|
1245
|
+
raw = response.read()
|
|
1246
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
1247
|
+
return None
|
|
1248
|
+
return _han_only(raw.decode(encoding, errors="replace"))
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def classify_compiled_teaching_quotes(
|
|
1252
|
+
quotes: list[tuple[str, str, str]],
|
|
1253
|
+
corpora: dict[str, dict],
|
|
1254
|
+
fetch,
|
|
1255
|
+
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str]], list[tuple[str, str]], list[str]]:
|
|
1256
|
+
"""到编集语录原书里逐字找人设当原话引的句子。
|
|
1257
|
+
|
|
1258
|
+
判「原书没有这句」只对 coverage 标 `complete` 的语料成立:正编、续编、三编就是
|
|
1259
|
+
《文钞》的全部,都取得到,找不到即伪造。虚云标 `partial` —— 净慧编的《开示录》
|
|
1260
|
+
比岑学吕的《法汇》多出六十余万字,BFNN 上没有,找不到只说明这一步够不着。
|
|
1261
|
+
任何一部取不到,也一律记未判定:接口不通不是证据。
|
|
1262
|
+
|
|
1263
|
+
第四个返回值是「整部语料一篇都没取到」的祖师 —— 那说明这一步对他什么也没检查。
|
|
1264
|
+
不把它单独报出来,一个取数早就坏掉的 3i 会年复一年地绿着,跟没有这道门禁一样。
|
|
1265
|
+
"""
|
|
1266
|
+
mismatched: list[tuple[str, str, str]] = []
|
|
1267
|
+
verified: list[tuple[str, str]] = []
|
|
1268
|
+
unknown: list[tuple[str, str]] = []
|
|
1269
|
+
bodies: dict[str, str | None] = {}
|
|
1270
|
+
touched: set[str] = set()
|
|
1271
|
+
for where, master, quote in quotes:
|
|
1272
|
+
corpus = corpora.get(master)
|
|
1273
|
+
if not corpus:
|
|
1274
|
+
continue
|
|
1275
|
+
touched.add(master)
|
|
1276
|
+
wanted = _han_only(quote)
|
|
1277
|
+
if len(wanted) < 8:
|
|
1278
|
+
unknown.append((where, "quote too short to search"))
|
|
1279
|
+
continue
|
|
1280
|
+
found_in, unreachable = None, []
|
|
1281
|
+
for text in corpus.get("texts") or []:
|
|
1282
|
+
url = str(text.get("url"))
|
|
1283
|
+
if url not in bodies:
|
|
1284
|
+
bodies[url] = fetch(url, text.get("encoding") or "utf-8")
|
|
1285
|
+
body = bodies[url]
|
|
1286
|
+
if body is None:
|
|
1287
|
+
unreachable.append(str(text.get("title")))
|
|
1288
|
+
elif wanted in body:
|
|
1289
|
+
found_in = str(text.get("title"))
|
|
1290
|
+
break
|
|
1291
|
+
if found_in:
|
|
1292
|
+
verified.append((where, found_in))
|
|
1293
|
+
elif unreachable:
|
|
1294
|
+
unknown.append((where, f"could not read {', '.join(sorted(set(unreachable)))}"))
|
|
1295
|
+
elif corpus.get("coverage") == "complete":
|
|
1296
|
+
mismatched.append((where, quote, str(corpus.get("corpus_title") or master)))
|
|
1297
|
+
else:
|
|
1298
|
+
unknown.append((where, f"{master}'s free full texts do not cover every declared compilation"))
|
|
1299
|
+
unreadable = sorted(
|
|
1300
|
+
str(corpora[master].get("corpus_title") or master)
|
|
1301
|
+
for master in touched
|
|
1302
|
+
if all(bodies.get(str(t.get("url"))) is None for t in corpora[master].get("texts") or [])
|
|
1303
|
+
)
|
|
1304
|
+
return mismatched, verified, unknown, unreadable
|
|
1305
|
+
|
|
1306
|
+
|
|
958
1307
|
def verify_ids(bridge, cbeta_map: dict[str, list[str]], titles: dict[str, str]) -> dict[str, dict]:
|
|
959
1308
|
"""Verify all CBETA IDs and return {full_cbeta_id: {text_id, short_id, title, ...}}.
|
|
960
1309
|
|
|
@@ -1274,6 +1623,58 @@ def _run_legacy_link_verification(*, fix: bool) -> int:
|
|
|
1274
1623
|
if not bdrc_mismatched and not bdrc_unknown:
|
|
1275
1624
|
print(f" All {len(bdrc_sources)} declared BDRC work ids resolve to a record with the declared title")
|
|
1276
1625
|
|
|
1626
|
+
# Step 3h: 人设里当原话引的句子,CBETA 里有没有(见 classify_persona_quotes)。
|
|
1627
|
+
print("\n[3h/4] Checking quoted lines in persona docs against CBETA...")
|
|
1628
|
+
persona_quotes = collect_persona_quotes()
|
|
1629
|
+
quote_line_mismatched, quote_line_unknown = classify_persona_quotes(
|
|
1630
|
+
persona_quotes, persona_source_families(), declared_cbeta_works(), cbeta_search_hits
|
|
1631
|
+
)
|
|
1632
|
+
for where, quote, clause in quote_line_mismatched:
|
|
1633
|
+
print(f" [WRONG] {where}: CBETA has no 「{clause}」 — 「{quote[:40]}」")
|
|
1634
|
+
if quote_line_unknown:
|
|
1635
|
+
print(f" Could not check {len(quote_line_unknown)} quoted line(s) — unknown, not wrong:")
|
|
1636
|
+
for where, reason in quote_line_unknown:
|
|
1637
|
+
print(f" {where}: {reason}")
|
|
1638
|
+
if not quote_line_mismatched and not quote_line_unknown:
|
|
1639
|
+
print(f" All {len(persona_quotes)} quoted lines are in CBETA, in a work the persona declares")
|
|
1640
|
+
|
|
1641
|
+
# Step 3i: 3h 够不着的那些 —— 祖师自己的语录本就在 CBETA 之外,进原书逐字找
|
|
1642
|
+
# (见 classify_compiled_teaching_quotes)。
|
|
1643
|
+
print("\n[3i/4] Checking quoted lines against compiled teachings CBETA does not hold...")
|
|
1644
|
+
compiled_corpora = compiled_teaching_corpora()
|
|
1645
|
+
compiled_mismatched, compiled_verified, compiled_unknown, compiled_unreadable = (
|
|
1646
|
+
classify_compiled_teaching_quotes(persona_quotes, compiled_corpora, fetch_compiled_text)
|
|
1647
|
+
)
|
|
1648
|
+
for where, quote, corpus_title in compiled_mismatched:
|
|
1649
|
+
print(f" [WRONG] {where}: {corpus_title} has no 「{quote[:40]}」")
|
|
1650
|
+
for title in compiled_unreadable:
|
|
1651
|
+
print(f" [BROKEN] {title}: not one declared full text loaded — this step checked nothing")
|
|
1652
|
+
if compiled_verified:
|
|
1653
|
+
print(f" Verified {len(compiled_verified)} quoted line(s) in the compiled teachings:")
|
|
1654
|
+
for where, title in compiled_verified:
|
|
1655
|
+
print(f" {where}: {title}")
|
|
1656
|
+
if compiled_unknown:
|
|
1657
|
+
print(f" Could not check {len(compiled_unknown)} quoted line(s) — unknown, not wrong:")
|
|
1658
|
+
for where, reason in compiled_unknown:
|
|
1659
|
+
print(f" {where}: {reason}")
|
|
1660
|
+
if not compiled_corpora:
|
|
1661
|
+
print(" No compiled-teaching corpora are declared")
|
|
1662
|
+
|
|
1663
|
+
# 同一步里的第二类:「原典」块本身。3f 只认带 CBETA 经号的引用格式,《文钞》
|
|
1664
|
+
# 没有经号,这些块此前对每一步检查都不可见(见 collect_compiled_excerpt_blocks)。
|
|
1665
|
+
compiled_blocks = collect_compiled_excerpt_blocks()
|
|
1666
|
+
block_mismatched, block_verified, block_unknown = classify_compiled_excerpt_blocks(
|
|
1667
|
+
compiled_blocks, compiled_corpora, fetch_compiled_text
|
|
1668
|
+
)
|
|
1669
|
+
for where, segment, citation in block_mismatched:
|
|
1670
|
+
print(f" [WRONG] {where}: {citation} has no 「{segment[:40]}」")
|
|
1671
|
+
if block_verified:
|
|
1672
|
+
print(f" Verified {len(block_verified)} 「原典」 block(s) word for word in the compiled teachings")
|
|
1673
|
+
if block_unknown:
|
|
1674
|
+
print(f" Could not check {len(block_unknown)} 「原典」 block(s) — unknown, not wrong:")
|
|
1675
|
+
for where, reason in block_unknown:
|
|
1676
|
+
print(f" {where}: {reason}")
|
|
1677
|
+
|
|
1277
1678
|
# Step 4: Update URLs
|
|
1278
1679
|
# Build replacement map: full_cbeta_id -> str(internal_text_id)
|
|
1279
1680
|
id_replacement_map: dict[str, str] = {}
|
|
@@ -1321,6 +1722,10 @@ def _run_legacy_link_verification(*, fix: bool) -> int:
|
|
|
1321
1722
|
print(f" Doc citation links to another work: {len(doc_mismatched)}")
|
|
1322
1723
|
print(f" Excerpt quotes not in the cited text: {len(quote_mismatched)}")
|
|
1323
1724
|
print(f" BDRC records that do not match: {len(bdrc_mismatched)}")
|
|
1725
|
+
print(f" Quoted lines CBETA does not have: {len(quote_line_mismatched)}")
|
|
1726
|
+
print(f" Quoted lines the compiled teachings do not have: {len(compiled_mismatched)}")
|
|
1727
|
+
print(f" Compiled teaching corpora that could not be read: {len(compiled_unreadable)}")
|
|
1728
|
+
print(f" Excerpt blocks the compiled teachings do not have: {len(block_mismatched)}")
|
|
1324
1729
|
if unknown_to_cbeta:
|
|
1325
1730
|
print(f" CBETA unreachable for: {len(unknown_to_cbeta)} (not counted as wrong)")
|
|
1326
1731
|
if dry_run and all_changes:
|
package/tools/version_manager.py
CHANGED
|
@@ -67,7 +67,14 @@ def rollback(teacher_dir: str, target_version: str) -> bool:
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
def cleanup_old_versions(teacher_dir: str) -> int:
|
|
70
|
-
"""Remove
|
|
70
|
+
"""Remove archived versions beyond MAX_VERSIONS, oldest first.
|
|
71
|
+
|
|
72
|
+
Prunes only what `list_versions` reports — directories named `v…` — and never a
|
|
73
|
+
`_before_rollback` copy. The first version selected *every* directory under
|
|
74
|
+
`versions/`, so anything else a maintainer kept there was deleted once the
|
|
75
|
+
archive passed the limit, and the backup `rollback` writes so that a bad
|
|
76
|
+
rollback can be undone was evicted by age like an ordinary version.
|
|
77
|
+
"""
|
|
71
78
|
versions_dir = os.path.join(teacher_dir, "versions")
|
|
72
79
|
if not os.path.exists(versions_dir):
|
|
73
80
|
return 0
|
|
@@ -75,8 +82,11 @@ def cleanup_old_versions(teacher_dir: str) -> int:
|
|
|
75
82
|
entries = []
|
|
76
83
|
for entry in os.listdir(versions_dir):
|
|
77
84
|
entry_path = os.path.join(versions_dir, entry)
|
|
78
|
-
if os.path.isdir(entry_path):
|
|
79
|
-
|
|
85
|
+
if not os.path.isdir(entry_path):
|
|
86
|
+
continue
|
|
87
|
+
if not entry.startswith("v") or entry.endswith("_before_rollback"):
|
|
88
|
+
continue
|
|
89
|
+
entries.append((entry_path, os.path.getmtime(entry_path)))
|
|
80
90
|
|
|
81
91
|
entries.sort(key=lambda x: x[1], reverse=True)
|
|
82
92
|
|