master-skill 0.8.0 → 0.9.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.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/README.md +16 -7
  5. package/README_EN.md +15 -6
  6. package/SKILL.md +4 -0
  7. package/bin/cli.mjs +58 -12
  8. package/gemini-extension.json +1 -1
  9. package/package.json +13 -10
  10. package/prebuilt/master-ajahn-chah/SKILL.md +35 -1
  11. package/prebuilt/master-atisha/SKILL.md +35 -1
  12. package/prebuilt/master-buddhaghosa/SKILL.md +35 -1
  13. package/prebuilt/master-fazang/SKILL.md +35 -1
  14. package/prebuilt/master-huineng/SKILL.md +35 -1
  15. package/prebuilt/master-huineng/tests/fidelity.jsonl +2 -0
  16. package/prebuilt/master-kumarajiva/SKILL.md +35 -1
  17. package/prebuilt/master-mahasi-sayadaw/SKILL.md +35 -1
  18. package/prebuilt/master-milarepa/SKILL.md +35 -1
  19. package/prebuilt/master-nagarjuna/SKILL.md +170 -0
  20. package/prebuilt/master-nagarjuna/meta.json +129 -0
  21. package/prebuilt/master-nagarjuna/references/teaching.md +112 -0
  22. package/prebuilt/master-nagarjuna/references/voice.md +100 -0
  23. package/prebuilt/master-nagarjuna/sources/INDEX.md +23 -0
  24. package/prebuilt/master-nagarjuna/sources/dazhidulun-excerpts.md +49 -0
  25. package/prebuilt/master-nagarjuna/sources/shizhu-yixing-excerpts.md +33 -0
  26. package/prebuilt/master-nagarjuna/sources/zhonglun-excerpts.md +94 -0
  27. package/prebuilt/master-nagarjuna/tests/fidelity.jsonl +10 -0
  28. package/prebuilt/master-ouyi/SKILL.md +35 -1
  29. package/prebuilt/master-tsongkhapa/SKILL.md +35 -1
  30. package/prebuilt/master-xuanzang/SKILL.md +35 -1
  31. package/prebuilt/master-xuyun/SKILL.md +35 -1
  32. package/prebuilt/master-yinguang/SKILL.md +35 -1
  33. package/prebuilt/master-zhiyi/SKILL.md +35 -1
  34. package/scripts/_masterpaths.py +32 -0
  35. package/scripts/cite.py +19 -4
  36. package/scripts/query.py +19 -4
  37. package/scripts/test-fidelity.py +35 -3
  38. package/scripts/tests/test_injection_hardening.py +174 -0
  39. package/scripts/verify_citations.py +152 -0
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """B1 引证核验器 — 抓出 master 回答里的幻觉引文(dev/CI 镜像)。
3
+
4
+ 这是每个 master SKILL.md 里「出答前引证自审」那条运行时规则的**确定性镜像**。
5
+ 运行时命门在 SKILL.md(指令驱动,随技能装机);本脚本只在 repo 内(有 Python)做 CI lint。
6
+
7
+ 规则:抽取答案中每个 `【…,<cbeta_id>】` 引文,判定——
8
+ - `cbeta_id` ∈ 本 master 声明的离线源(meta.json sources[].id) → offline,放行;
9
+ - 否则其后近邻出现 `fojin.app/texts/{N}` 数字链接 → live,放行(`--online` 再验 N 可解析);
10
+ - 两者都不满足 → fabricated(幻觉引文),exit 1。
11
+
12
+ 离线判定纯确定性、零网络、零 LLM,可作 CI 硬门。`--online` 为可选增强,网络不可达时仅告警。
13
+
14
+ 用法:
15
+ python scripts/verify_citations.py --master huineng --answer-file ans.md
16
+ echo "…答案…" | python scripts/verify_citations.py --master huineng
17
+ python scripts/verify_citations.py --master huineng --answer-file ans.md --online
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+
27
+ from _masterpaths import resolve_master_dir
28
+
29
+ # master feeds into path resolution; restrict to a slug charset so a value like
30
+ # "../../etc" can never read files outside prebuilt/. Mirrors the isSafeName
31
+ # guard in bin/cli.mjs and scripts/query.py.
32
+ _SAFE_MASTER = re.compile(r"^[A-Za-z0-9_-]+$")
33
+
34
+ # CBETA id 形态:T48n2008 / T08n0235(藏经卷+n+编号),及 API 返回的 X1218 / X0303
35
+ # (无卷号)。无 `n` 的形态只认 T/X 两个集合,避免误吞 Wikidata 的 Q1234 / P5008。
36
+ _CBETA_ID = re.compile(r"\b(?:[A-Z]{1,2}\d+n\d+|[TX]\d{3,})\b")
37
+ # 引文块 【…】
38
+ _CITATION_BLOCK = re.compile(r"【([^】]*)】")
39
+ # live 链接 fojin.app/texts/<数字>
40
+ _FOJIN_TEXT_LINK = re.compile(r"fojin\.app/texts/(\d+)")
41
+ # 引文块「之后」多远内出现 live 链接仍算本块携带(且不跨过下一引文块)。link 须在引文之后。
42
+ _LINK_WINDOW = 120
43
+
44
+
45
+ def load_declared_ids(master: str) -> set[str]:
46
+ """读 prebuilt/<master>/meta.json,返回声明的离线 cbeta_id 集合。"""
47
+ if not _SAFE_MASTER.match(master):
48
+ raise ValueError(f"无效的 master ID:{master!r}(仅允许字母、数字、'-'、'_')")
49
+ master_dir = resolve_master_dir(master) # 兼容 "huineng" / "master-huineng"
50
+ if master_dir is None:
51
+ raise FileNotFoundError(f"找不到 master:{master!r}(试过 {master!r} 和 master-{master})")
52
+ with open(os.path.join(master_dir, "meta.json"), encoding="utf-8") as f:
53
+ meta = json.load(f)
54
+ ids: set[str] = set()
55
+ for src in meta.get("sources", []):
56
+ sid = src.get("id")
57
+ if sid:
58
+ ids.add(sid)
59
+ ids.update(meta.get("search_scope", {}).get("primary_cbeta_ids", []))
60
+ return ids
61
+
62
+
63
+ def audit_answer(declared_ids: set[str], answer: str) -> dict:
64
+ """把答案里每条引文分类为 offline / live / fabricated。
65
+
66
+ 返回 {'offline': [...], 'live': [(cbeta_id, text_id), ...], 'fabricated': [...]}。
67
+ """
68
+ offline: list[str] = []
69
+ live: list[tuple[str, str]] = []
70
+ fabricated: list[str] = []
71
+
72
+ blocks = list(_CITATION_BLOCK.finditer(answer))
73
+ for idx, m in enumerate(blocks):
74
+ ids = _CBETA_ID.findall(m.group(1))
75
+ if not ids:
76
+ continue
77
+ # 链接归属:本引文块结束 → 下一引文块开始(且不超过 _LINK_WINDOW)。这样一个 link
78
+ # 只能洗白紧挨它之前的那一个引文块,不会连带洗白更前面的伪造引文(B1 的关键)。
79
+ next_start = blocks[idx + 1].start() if idx + 1 < len(blocks) else len(answer)
80
+ region_end = min(next_start, m.end() + _LINK_WINDOW)
81
+ link = _FOJIN_TEXT_LINK.search(answer, m.end(), region_end)
82
+ for cid in ids:
83
+ if cid in declared_ids:
84
+ offline.append(cid)
85
+ elif link:
86
+ live.append((cid, link.group(1)))
87
+ else:
88
+ fabricated.append(cid)
89
+ return {"offline": offline, "live": live, "fabricated": fabricated}
90
+
91
+
92
+ def verify_online(text_ids: list[str], base_url: str = "https://fojin.app", timeout: int = 15) -> dict:
93
+ """best-effort:GET /api/texts/{id} 看 live 引文的 text_id 是否真解析。
94
+
95
+ 网络不可达时返回 {'_unreachable': True},调用方按告警处理(不硬失败)。
96
+ """
97
+ try:
98
+ import requests
99
+ except ImportError:
100
+ return {"_unreachable": True, "_reason": "requests 未安装"}
101
+ out: dict = {}
102
+ sess = requests.Session()
103
+ for tid in text_ids:
104
+ try:
105
+ r = sess.get(f"{base_url}/api/texts/{tid}", timeout=timeout)
106
+ out[tid] = r.status_code == 200 and bool(r.json())
107
+ except Exception as e: # noqa: BLE001 — 网络层一律降级为不可达
108
+ return {"_unreachable": True, "_reason": str(e)}
109
+ return out
110
+
111
+
112
+ def main() -> int:
113
+ p = argparse.ArgumentParser(description="B1 引证核验器")
114
+ p.add_argument("--master", required=True, help="master slug,如 huineng")
115
+ p.add_argument("--answer-file", help="答案文件;省略则从 stdin 读")
116
+ p.add_argument("--online", action="store_true", help="额外验证 live 引文 text_id 可解析")
117
+ args = p.parse_args()
118
+
119
+ try:
120
+ declared = load_declared_ids(args.master)
121
+ except (ValueError, FileNotFoundError) as e:
122
+ print(f"✗ {e}", file=sys.stderr)
123
+ return 2
124
+
125
+ answer = open(args.answer_file, encoding="utf-8").read() if args.answer_file else sys.stdin.read()
126
+ report = audit_answer(declared, answer)
127
+
128
+ print(f"offline 引文: {len(report['offline'])} live 引文: {len(report['live'])} "
129
+ f"fabricated: {len(report['fabricated'])}")
130
+
131
+ exit_code = 0
132
+ if report["fabricated"]:
133
+ print(f"✗ 幻觉引文(既非声明源,又无 live 链接): {sorted(set(report['fabricated']))}", file=sys.stderr)
134
+ exit_code = 1
135
+
136
+ if args.online and report["live"]:
137
+ res = verify_online([tid for _, tid in report["live"]])
138
+ if res.get("_unreachable"):
139
+ print(f"⚠ --online 跳过:FoJin 不可达({res.get('_reason')})", file=sys.stderr)
140
+ else:
141
+ bad = [tid for tid, ok in res.items() if not ok]
142
+ if bad:
143
+ print(f"✗ live 引文 text_id 无法解析: {bad}", file=sys.stderr)
144
+ exit_code = 1
145
+
146
+ if exit_code == 0:
147
+ print("✓ 全部引文可核验")
148
+ return exit_code
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())