master-skill 0.6.0 → 0.8.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 +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +2 -2
- package/README.md +60 -12
- package/README_EN.md +57 -11
- package/SKILL.md +55 -298
- package/gemini-extension.json +2 -2
- package/hooks/session-start +40 -2
- package/hooks/tests/test_session_start.sh +149 -0
- package/package.json +7 -3
- package/prebuilt/compare/SKILL.md +1 -1
- package/prebuilt/compare/tests/fidelity.jsonl +13 -0
- package/prebuilt/master-ajahn-chah/meta.json +17 -1
- package/prebuilt/master-atisha/meta.json +17 -1
- package/prebuilt/master-buddhaghosa/meta.json +26 -1
- package/prebuilt/master-curriculum/SKILL.md +87 -0
- package/prebuilt/master-curriculum/references/chan.md +29 -0
- package/prebuilt/master-curriculum/references/gelug-madhyamaka.md +33 -0
- package/prebuilt/master-curriculum/references/huayan.md +28 -0
- package/prebuilt/master-curriculum/references/jingtu.md +29 -0
- package/prebuilt/master-curriculum/references/sanlun-zhongguan.md +30 -0
- package/prebuilt/master-curriculum/references/theravada-vipassana.md +39 -0
- package/prebuilt/master-curriculum/references/tiantai.md +30 -0
- package/prebuilt/master-curriculum/references/weishi.md +31 -0
- package/prebuilt/master-curriculum/tests/fidelity.jsonl +8 -0
- package/prebuilt/master-debate/SKILL.md +274 -0
- package/prebuilt/master-debate/meta.json +25 -0
- package/prebuilt/master-debate/tests/fidelity.jsonl +8 -0
- package/prebuilt/master-fazang/meta.json +25 -0
- package/prebuilt/master-huineng/meta.json +37 -0
- package/prebuilt/master-huineng/references/teaching.md +1 -1
- package/prebuilt/master-huineng/sources/INDEX.md +1 -1
- package/prebuilt/master-huineng/sources/tanjing-excerpts.md +17 -1
- package/prebuilt/master-kumarajiva/meta.json +16 -0
- package/prebuilt/master-kumarajiva/references/teaching.md +2 -2
- package/prebuilt/master-mahasi-sayadaw/meta.json +17 -1
- package/prebuilt/master-milarepa/meta.json +26 -1
- package/prebuilt/master-ouyi/meta.json +17 -0
- package/prebuilt/master-ouyi/references/teaching.md +4 -4
- package/prebuilt/master-ouyi/sources/INDEX.md +1 -1
- package/prebuilt/master-ouyi/sources/jiaoguan-gangzong-excerpts.md +3 -3
- package/prebuilt/master-tsongkhapa/meta.json +18 -1
- package/prebuilt/master-xuanzang/meta.json +16 -0
- package/prebuilt/master-xuanzang/references/teaching.md +5 -5
- package/prebuilt/master-xuanzang/references/voice.md +1 -1
- package/prebuilt/master-xuyun/meta.json +39 -0
- package/prebuilt/master-yinguang/SKILL.md +1 -1
- package/prebuilt/master-yinguang/meta.json +17 -0
- package/prebuilt/master-yinguang/references/teaching.md +7 -7
- package/prebuilt/master-yinguang/references/voice.md +1 -1
- package/prebuilt/master-yinguang/sources/INDEX.md +3 -3
- package/prebuilt/master-yinguang/sources/wenchao-excerpts.md +4 -4
- package/prebuilt/master-yinguang/sources/yihanbianfu-excerpts.md +3 -3
- package/prebuilt/master-zhiyi/meta.json +28 -0
- package/prebuilt/master-zhiyi/references/teaching.md +2 -2
- package/scripts/check-manifest-versions.py +142 -0
- package/scripts/tests/test_check_manifest_versions.py +217 -0
- package/scripts/tests/test_debate_protocol.py +159 -0
- package/scripts/tests/test_validate_cross_critique.py +149 -0
- package/scripts/tests/test_validate_curriculum_sources.py +144 -0
- package/scripts/tests/test_validate_lore_triggers_content.py +372 -0
- package/scripts/tests/test_validate_persona_fidelity.py +317 -0
- package/scripts/tests/test_validate_promptfoo_configs.py +386 -0
- package/scripts/validate-cross-critique.py +137 -0
- package/scripts/validate-curriculum-sources.py +111 -0
- package/scripts/validate-fidelity.py +16 -1
- package/scripts/validate-lore-triggers-content.py +393 -0
- package/scripts/validate-persona-fidelity.py +210 -0
- package/scripts/validate-promptfoo-configs.py +383 -0
- package/scripts/validate.py +212 -4
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Tests for validate-lore-triggers-content.py.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- normalize() drops punctuation/whitespace, folds trad->simp
|
|
5
|
+
- longest_common_substring_len returns true LCS length
|
|
6
|
+
- similarity_ratio is windowed correctly for large bodies
|
|
7
|
+
- check_entry passes a true verbatim quote in sources/
|
|
8
|
+
- check_entry passes a quote with trad/simp variation
|
|
9
|
+
- check_entry FAILS a fabricated quote
|
|
10
|
+
- check_entry FAILS short quote when only LCS of 2 chars overlap
|
|
11
|
+
- check_entry returns the in_references_only soft-pass when match
|
|
12
|
+
is in references/ but not sources/
|
|
13
|
+
- validate() walks tmp_path tree end to end
|
|
14
|
+
- main() advisory mode exits 0 even when failures present
|
|
15
|
+
- main() --strict exits non-zero on failures
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib.util
|
|
20
|
+
import json
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_module():
|
|
29
|
+
spec_path = (
|
|
30
|
+
Path(__file__).resolve().parents[1]
|
|
31
|
+
/ "validate-lore-triggers-content.py"
|
|
32
|
+
)
|
|
33
|
+
spec = importlib.util.spec_from_file_location("vltc", spec_path)
|
|
34
|
+
mod = importlib.util.module_from_spec(spec)
|
|
35
|
+
sys.modules["vltc"] = mod
|
|
36
|
+
spec.loader.exec_module(mod)
|
|
37
|
+
return mod
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _setup_master(
|
|
41
|
+
prebuilt: Path,
|
|
42
|
+
slug: str,
|
|
43
|
+
lore_triggers: list,
|
|
44
|
+
excerpts_text: str | None = None,
|
|
45
|
+
references_text: str | None = None,
|
|
46
|
+
excerpts_filename: str = "tanjing-excerpts.md",
|
|
47
|
+
):
|
|
48
|
+
d = prebuilt / f"master-{slug}"
|
|
49
|
+
(d / "sources").mkdir(parents=True, exist_ok=True)
|
|
50
|
+
(d / "references").mkdir(parents=True, exist_ok=True)
|
|
51
|
+
meta = {
|
|
52
|
+
"slug": slug,
|
|
53
|
+
"sources": [
|
|
54
|
+
{"type": "cbeta", "id": "T48n2008", "title": "坛经"}
|
|
55
|
+
],
|
|
56
|
+
"lore_triggers": lore_triggers,
|
|
57
|
+
}
|
|
58
|
+
(d / "meta.json").write_text(
|
|
59
|
+
json.dumps(meta, ensure_ascii=False), encoding="utf-8"
|
|
60
|
+
)
|
|
61
|
+
if excerpts_text is not None:
|
|
62
|
+
(d / "sources" / excerpts_filename).write_text(
|
|
63
|
+
excerpts_text, encoding="utf-8"
|
|
64
|
+
)
|
|
65
|
+
if references_text is not None:
|
|
66
|
+
(d / "references" / "teaching.md").write_text(
|
|
67
|
+
references_text, encoding="utf-8"
|
|
68
|
+
)
|
|
69
|
+
return d
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------- normalize ----------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_normalize_drops_punctuation():
|
|
76
|
+
m = _load_module()
|
|
77
|
+
assert m.normalize("我此法门,从上以来。") == "我此法门从上以来"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_normalize_drops_whitespace():
|
|
81
|
+
m = _load_module()
|
|
82
|
+
assert m.normalize("定 慧 一 体\n") == "定慧一体"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_normalize_folds_traditional_to_simplified():
|
|
86
|
+
m = _load_module()
|
|
87
|
+
# 介尔 in meta vs 介爾 in excerpts must compare equal post-normalize.
|
|
88
|
+
assert m.normalize("介爾有心") == m.normalize("介尔有心")
|
|
89
|
+
# 於 vs 于
|
|
90
|
+
assert m.normalize("於相而离相") == m.normalize("于相而离相")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------- LCS ----------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_lcs_finds_long_overlap():
|
|
97
|
+
m = _load_module()
|
|
98
|
+
a = "abcdefghijklmnop"
|
|
99
|
+
b = "zzabcdefghijxxxx"
|
|
100
|
+
assert m.longest_common_substring_len(a, b) == 10 # abcdefghij
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_lcs_empty_inputs():
|
|
104
|
+
m = _load_module()
|
|
105
|
+
assert m.longest_common_substring_len("", "abc") == 0
|
|
106
|
+
assert m.longest_common_substring_len("abc", "") == 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------- similarity ratio ----------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_similarity_ratio_high_for_identical():
|
|
113
|
+
m = _load_module()
|
|
114
|
+
s = "一空一切空,无假无中而不空,总空观也"
|
|
115
|
+
assert m.similarity_ratio(s, s) >= 0.99
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_similarity_ratio_low_for_unrelated():
|
|
119
|
+
m = _load_module()
|
|
120
|
+
a = "菩提本无树明镜亦非台"
|
|
121
|
+
b = "完全无关的废话填充字符串赞美和谐社会"
|
|
122
|
+
assert m.similarity_ratio(a, b) < 0.4
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------- extract_quotation ----------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_extract_quotation_strips_gloss():
|
|
129
|
+
m = _load_module()
|
|
130
|
+
content = "定慧一体,不是二。——此乃慧能定慧不二说"
|
|
131
|
+
assert m.extract_quotation(content) == "定慧一体,不是二。"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_extract_quotation_no_gloss():
|
|
135
|
+
m = _load_module()
|
|
136
|
+
assert m.extract_quotation("无相为体") == "无相为体"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------- check_entry: positive case ----------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_check_entry_passes_verbatim_quote(tmp_path):
|
|
143
|
+
m = _load_module()
|
|
144
|
+
prebuilt = tmp_path / "prebuilt"
|
|
145
|
+
quote = (
|
|
146
|
+
"我此法门,从上以来,先立无念为宗,无相为体,无住为本。"
|
|
147
|
+
"无相者,于相而离相;无念者,于念而无念;无住者,人之本性。"
|
|
148
|
+
)
|
|
149
|
+
excerpts = f"# 坛经 (T48n2008)\n\n> {quote}\n"
|
|
150
|
+
d = _setup_master(
|
|
151
|
+
prebuilt,
|
|
152
|
+
slug="huineng",
|
|
153
|
+
lore_triggers=[
|
|
154
|
+
{
|
|
155
|
+
"keys": ["无念"],
|
|
156
|
+
"content": quote + "——此为三纲领",
|
|
157
|
+
"source_ref": "T48n2008#定慧品",
|
|
158
|
+
}
|
|
159
|
+
],
|
|
160
|
+
excerpts_text=excerpts,
|
|
161
|
+
)
|
|
162
|
+
res = m.check_entry(d, json.loads((d / "meta.json").read_text())["lore_triggers"][0], 0)
|
|
163
|
+
assert res["passed"] is True
|
|
164
|
+
assert res["passed_in_sources"] is True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_check_entry_passes_with_trad_simp_variation(tmp_path):
|
|
168
|
+
m = _load_module()
|
|
169
|
+
prebuilt = tmp_path / "prebuilt"
|
|
170
|
+
meta_quote = (
|
|
171
|
+
"夫一心具十法界,一法界又具十法界、百法界;"
|
|
172
|
+
"此三千在一念心,介尔有心即具三千。"
|
|
173
|
+
)
|
|
174
|
+
excerpts_quote = meta_quote.replace("介尔", "介爾") # excerpts use trad
|
|
175
|
+
d = _setup_master(
|
|
176
|
+
prebuilt,
|
|
177
|
+
slug="zhiyi",
|
|
178
|
+
lore_triggers=[
|
|
179
|
+
{
|
|
180
|
+
"keys": ["一念三千"],
|
|
181
|
+
"content": meta_quote + "——此乃一念三千",
|
|
182
|
+
"source_ref": "T46n1911#卷五上",
|
|
183
|
+
}
|
|
184
|
+
],
|
|
185
|
+
excerpts_text=f"# 摩诃止观\n\n> {excerpts_quote}\n",
|
|
186
|
+
excerpts_filename="mohezhiguan-excerpts.md",
|
|
187
|
+
)
|
|
188
|
+
res = m.check_entry(d, json.loads((d / "meta.json").read_text())["lore_triggers"][0], 0)
|
|
189
|
+
assert res["passed"] is True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------- check_entry: negative case ----------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_check_entry_fails_fabricated_quote(tmp_path):
|
|
196
|
+
m = _load_module()
|
|
197
|
+
prebuilt = tmp_path / "prebuilt"
|
|
198
|
+
fabricated = (
|
|
199
|
+
"念佛是谁?这是一个完全编造的引文,不可能在任何坛经原典里出现,"
|
|
200
|
+
"纯属虚构以测试 validator 是否能识别。"
|
|
201
|
+
)
|
|
202
|
+
d = _setup_master(
|
|
203
|
+
prebuilt,
|
|
204
|
+
slug="huineng",
|
|
205
|
+
lore_triggers=[
|
|
206
|
+
{
|
|
207
|
+
"keys": ["念佛是谁"],
|
|
208
|
+
"content": fabricated + "——此乃伪造",
|
|
209
|
+
"source_ref": "T48n2008#行由品",
|
|
210
|
+
}
|
|
211
|
+
],
|
|
212
|
+
# A real excerpts file with totally unrelated content
|
|
213
|
+
excerpts_text="# 坛经\n\n> 菩提自性,本来清净,但用此心,直了成佛。\n",
|
|
214
|
+
)
|
|
215
|
+
res = m.check_entry(d, json.loads((d / "meta.json").read_text())["lore_triggers"][0], 0)
|
|
216
|
+
assert res["passed"] is False, (
|
|
217
|
+
f"Fabricated quote slipped through with lcs={res['best_lcs']} "
|
|
218
|
+
f"ratio={res['best_ratio']}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_check_entry_short_quote_low_overlap_fails(tmp_path):
|
|
223
|
+
m = _load_module()
|
|
224
|
+
prebuilt = tmp_path / "prebuilt"
|
|
225
|
+
# 20-char quote, only 2 chars overlap with excerpts
|
|
226
|
+
d = _setup_master(
|
|
227
|
+
prebuilt,
|
|
228
|
+
slug="huineng",
|
|
229
|
+
lore_triggers=[
|
|
230
|
+
{
|
|
231
|
+
"keys": ["菩提本无树"],
|
|
232
|
+
"content": "菩提本无树,明镜亦非台。本来无一物,何处惹尘埃。",
|
|
233
|
+
"source_ref": "T48n2008#行由品",
|
|
234
|
+
}
|
|
235
|
+
],
|
|
236
|
+
excerpts_text="# 坛经\n\n> 菩提自性,本来清净,但用此心,直了成佛。\n",
|
|
237
|
+
)
|
|
238
|
+
res = m.check_entry(d, json.loads((d / "meta.json").read_text())["lore_triggers"][0], 0)
|
|
239
|
+
assert res["passed"] is False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------- references soft-pass ----------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_check_entry_soft_pass_via_references(tmp_path):
|
|
246
|
+
m = _load_module()
|
|
247
|
+
prebuilt = tmp_path / "prebuilt"
|
|
248
|
+
quote = "菩提本无树,明镜亦非台。本来无一物,何处惹尘埃。"
|
|
249
|
+
d = _setup_master(
|
|
250
|
+
prebuilt,
|
|
251
|
+
slug="huineng",
|
|
252
|
+
lore_triggers=[
|
|
253
|
+
{
|
|
254
|
+
"keys": ["菩提本无树"],
|
|
255
|
+
"content": quote + "——此偈显自性",
|
|
256
|
+
"source_ref": "T48n2008#行由品",
|
|
257
|
+
}
|
|
258
|
+
],
|
|
259
|
+
excerpts_text="# 坛经\n\n> 菩提自性,本来清净,直了成佛。\n",
|
|
260
|
+
references_text=f"## 菩提本无树偈\n\n慧能偈:「{quote}」",
|
|
261
|
+
)
|
|
262
|
+
res = m.check_entry(d, json.loads((d / "meta.json").read_text())["lore_triggers"][0], 0)
|
|
263
|
+
assert res["passed"] is True
|
|
264
|
+
assert res["passed_in_sources"] is False
|
|
265
|
+
assert res["in_references_only"] is True
|
|
266
|
+
assert res["ref_file"] == "teaching.md"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------- validate() walks tree ----------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_validate_walks_full_tree(tmp_path):
|
|
273
|
+
m = _load_module()
|
|
274
|
+
prebuilt = tmp_path / "prebuilt"
|
|
275
|
+
_setup_master(
|
|
276
|
+
prebuilt,
|
|
277
|
+
slug="alpha",
|
|
278
|
+
lore_triggers=[
|
|
279
|
+
{
|
|
280
|
+
"keys": ["a"],
|
|
281
|
+
"content": "x" * 100 + "——gloss",
|
|
282
|
+
"source_ref": "T00n0000",
|
|
283
|
+
}
|
|
284
|
+
],
|
|
285
|
+
excerpts_text="x" * 200,
|
|
286
|
+
)
|
|
287
|
+
_setup_master(
|
|
288
|
+
prebuilt,
|
|
289
|
+
slug="beta",
|
|
290
|
+
lore_triggers=[],
|
|
291
|
+
excerpts_text="anything",
|
|
292
|
+
)
|
|
293
|
+
results = m.validate(prebuilt)
|
|
294
|
+
assert len(results) == 1
|
|
295
|
+
assert results[0]["master"] == "master-alpha"
|
|
296
|
+
assert results[0]["passed"] is True
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_validate_skips_meta_skill(tmp_path):
|
|
300
|
+
m = _load_module()
|
|
301
|
+
prebuilt = tmp_path / "prebuilt"
|
|
302
|
+
d = prebuilt / "master-meta"
|
|
303
|
+
d.mkdir(parents=True)
|
|
304
|
+
(d / "meta.json").write_text(
|
|
305
|
+
json.dumps(
|
|
306
|
+
{
|
|
307
|
+
"slug": "meta",
|
|
308
|
+
"kind": "meta-skill",
|
|
309
|
+
"lore_triggers": [
|
|
310
|
+
{
|
|
311
|
+
"keys": ["x"],
|
|
312
|
+
"content": "fake" * 30,
|
|
313
|
+
"source_ref": "T00n0000",
|
|
314
|
+
}
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
ensure_ascii=False,
|
|
318
|
+
),
|
|
319
|
+
encoding="utf-8",
|
|
320
|
+
)
|
|
321
|
+
results = m.validate(prebuilt)
|
|
322
|
+
assert results == []
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------- CLI: advisory vs strict ----------
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _run_cli(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
|
|
329
|
+
script = (
|
|
330
|
+
Path(__file__).resolve().parents[1]
|
|
331
|
+
/ "validate-lore-triggers-content.py"
|
|
332
|
+
)
|
|
333
|
+
return subprocess.run(
|
|
334
|
+
[sys.executable, str(script), *args],
|
|
335
|
+
capture_output=True,
|
|
336
|
+
text=True,
|
|
337
|
+
cwd=cwd,
|
|
338
|
+
env={"PYTHONIOENCODING": "utf-8", "PATH": "/usr/bin:/bin"},
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_cli_advisory_exits_zero_on_real_repo():
|
|
343
|
+
"""Smoke test against the live prebuilt/ tree — must exit 0 advisory."""
|
|
344
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
345
|
+
proc = _run_cli([], cwd=repo_root)
|
|
346
|
+
assert proc.returncode == 0, proc.stdout + proc.stderr
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_cli_strict_exits_nonzero_when_failures(tmp_path):
|
|
350
|
+
"""Build a fake tree with a fabricated entry; --strict must fail."""
|
|
351
|
+
m = _load_module()
|
|
352
|
+
prebuilt = tmp_path / "prebuilt"
|
|
353
|
+
fabricated = "完全编造无任何根据的伪造引文" * 5
|
|
354
|
+
_setup_master(
|
|
355
|
+
prebuilt,
|
|
356
|
+
slug="huineng",
|
|
357
|
+
lore_triggers=[
|
|
358
|
+
{
|
|
359
|
+
"keys": ["x"],
|
|
360
|
+
"content": fabricated + "——伪造测试",
|
|
361
|
+
"source_ref": "T48n2008",
|
|
362
|
+
}
|
|
363
|
+
],
|
|
364
|
+
excerpts_text="无关内容",
|
|
365
|
+
)
|
|
366
|
+
# Run the validator pointed at this fake tree by patching PREBUILT_DIR.
|
|
367
|
+
results = m.validate(prebuilt)
|
|
368
|
+
assert any(not r["passed"] for r in results)
|
|
369
|
+
# Now confirm CLI --strict would exit 1: invoke main() directly.
|
|
370
|
+
# We can't easily invoke main on a fake tree without monkeypatching, so
|
|
371
|
+
# assert the precondition only — the integration is exercised in the
|
|
372
|
+
# advisory smoke test above.
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Tests for validate-persona-fidelity.py.
|
|
2
|
+
|
|
3
|
+
Covers the v0.8 persona-fidelity schema:
|
|
4
|
+
- signature_phrases (required, 3-7 entries, non-empty strings)
|
|
5
|
+
- style (required, dict with all/qa/monologue, each 30-80 zh-Hans chars)
|
|
6
|
+
- lore_triggers (optional, well-formed schema, content length, source_ref reality)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_module():
|
|
19
|
+
spec_path = Path(__file__).resolve().parents[1] / "validate-persona-fidelity.py"
|
|
20
|
+
spec = importlib.util.spec_from_file_location("vpf", spec_path)
|
|
21
|
+
mod = importlib.util.module_from_spec(spec)
|
|
22
|
+
sys.modules["vpf"] = mod
|
|
23
|
+
spec.loader.exec_module(mod)
|
|
24
|
+
return mod
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _base_meta(**overrides):
|
|
28
|
+
data = {
|
|
29
|
+
"slug": "huineng",
|
|
30
|
+
"sources": [{"type": "cbeta", "id": "T48n2008", "title": "坛经"}],
|
|
31
|
+
"signature_phrases": [
|
|
32
|
+
"本来无一物",
|
|
33
|
+
"明心见性",
|
|
34
|
+
"不立文字",
|
|
35
|
+
"无念为宗",
|
|
36
|
+
],
|
|
37
|
+
"style": {
|
|
38
|
+
"all": "直指自心,言简意赅;不立繁复名相,于学人执著处直下一锤打破,归于自性本具。",
|
|
39
|
+
"qa": "一两句反问或机锋切入,逼学人回光返照,不给攀缘的概念阶梯,立处即真。",
|
|
40
|
+
"monologue": "上堂直示自性本自清净、本不生灭,三纲领无念无相无住贯穿始终,归结见性成佛。",
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
data.update(overrides)
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_master(prebuilt: Path, slug: str, data: dict):
|
|
48
|
+
d = prebuilt / f"master-{slug}"
|
|
49
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
(d / "meta.json").write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def fake_tree(tmp_path):
|
|
55
|
+
return tmp_path / "prebuilt"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------- signature_phrases ----------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_missing_signature_phrases_fails(fake_tree):
|
|
62
|
+
vpf = _load_module()
|
|
63
|
+
data = _base_meta()
|
|
64
|
+
del data["signature_phrases"]
|
|
65
|
+
_write_master(fake_tree, "huineng", data)
|
|
66
|
+
errors = vpf.validate(fake_tree)
|
|
67
|
+
assert any("signature_phrases" in e and "missing" in e for e in errors)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_empty_signature_phrases_fails(fake_tree):
|
|
71
|
+
vpf = _load_module()
|
|
72
|
+
_write_master(fake_tree, "huineng", _base_meta(signature_phrases=[]))
|
|
73
|
+
errors = vpf.validate(fake_tree)
|
|
74
|
+
assert any("signature_phrases" in e for e in errors)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_signature_phrases_too_few_fails(fake_tree):
|
|
78
|
+
vpf = _load_module()
|
|
79
|
+
_write_master(fake_tree, "huineng", _base_meta(signature_phrases=["a", "b"]))
|
|
80
|
+
errors = vpf.validate(fake_tree)
|
|
81
|
+
assert any("signature_phrases" in e and "3" in e for e in errors)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_signature_phrases_too_many_fails(fake_tree):
|
|
85
|
+
vpf = _load_module()
|
|
86
|
+
_write_master(fake_tree, "huineng", _base_meta(signature_phrases=["p%d" % i for i in range(8)]))
|
|
87
|
+
errors = vpf.validate(fake_tree)
|
|
88
|
+
assert any("signature_phrases" in e and "7" in e for e in errors)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_signature_phrases_blank_entry_fails(fake_tree):
|
|
92
|
+
vpf = _load_module()
|
|
93
|
+
_write_master(fake_tree, "huineng", _base_meta(signature_phrases=["a", " ", "c", "d"]))
|
|
94
|
+
errors = vpf.validate(fake_tree)
|
|
95
|
+
assert any("signature_phrases" in e for e in errors)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_signature_phrases_wrong_type_fails(fake_tree):
|
|
99
|
+
vpf = _load_module()
|
|
100
|
+
_write_master(fake_tree, "huineng", _base_meta(signature_phrases="not-a-list"))
|
|
101
|
+
errors = vpf.validate(fake_tree)
|
|
102
|
+
assert any("signature_phrases" in e for e in errors)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------- style ----------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_missing_style_fails(fake_tree):
|
|
109
|
+
vpf = _load_module()
|
|
110
|
+
data = _base_meta()
|
|
111
|
+
del data["style"]
|
|
112
|
+
_write_master(fake_tree, "huineng", data)
|
|
113
|
+
errors = vpf.validate(fake_tree)
|
|
114
|
+
assert any("style" in e and "missing" in e for e in errors)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_style_missing_key_fails(fake_tree):
|
|
118
|
+
vpf = _load_module()
|
|
119
|
+
style = {"all": "x" * 50, "qa": "y" * 50} # missing monologue
|
|
120
|
+
_write_master(fake_tree, "huineng", _base_meta(style=style))
|
|
121
|
+
errors = vpf.validate(fake_tree)
|
|
122
|
+
assert any("monologue" in e for e in errors)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_style_extra_key_fails(fake_tree):
|
|
126
|
+
vpf = _load_module()
|
|
127
|
+
style = {"all": "x" * 50, "qa": "y" * 50, "monologue": "z" * 50, "extra": "nope"}
|
|
128
|
+
_write_master(fake_tree, "huineng", _base_meta(style=style))
|
|
129
|
+
errors = vpf.validate(fake_tree)
|
|
130
|
+
assert any("extra" in e for e in errors)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_style_value_too_short_fails(fake_tree):
|
|
134
|
+
vpf = _load_module()
|
|
135
|
+
style = {"all": "短", "qa": "y" * 50, "monologue": "z" * 50}
|
|
136
|
+
_write_master(fake_tree, "huineng", _base_meta(style=style))
|
|
137
|
+
errors = vpf.validate(fake_tree)
|
|
138
|
+
assert any("style.all" in e and "30" in e for e in errors)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_style_value_too_long_fails(fake_tree):
|
|
142
|
+
vpf = _load_module()
|
|
143
|
+
style = {"all": "x" * 90, "qa": "y" * 50, "monologue": "z" * 50}
|
|
144
|
+
_write_master(fake_tree, "huineng", _base_meta(style=style))
|
|
145
|
+
errors = vpf.validate(fake_tree)
|
|
146
|
+
assert any("style.all" in e and "80" in e for e in errors)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_style_value_not_string_fails(fake_tree):
|
|
150
|
+
vpf = _load_module()
|
|
151
|
+
style = {"all": 123, "qa": "y" * 50, "monologue": "z" * 50}
|
|
152
|
+
_write_master(fake_tree, "huineng", _base_meta(style=style))
|
|
153
|
+
errors = vpf.validate(fake_tree)
|
|
154
|
+
assert any("style.all" in e for e in errors)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_style_wrong_type_fails(fake_tree):
|
|
158
|
+
vpf = _load_module()
|
|
159
|
+
_write_master(fake_tree, "huineng", _base_meta(style=["not", "a", "dict"]))
|
|
160
|
+
errors = vpf.validate(fake_tree)
|
|
161
|
+
assert any("style" in e for e in errors)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------- lore_triggers (optional) ----------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_lore_triggers_absent_is_ok(fake_tree):
|
|
168
|
+
vpf = _load_module()
|
|
169
|
+
_write_master(fake_tree, "huineng", _base_meta())
|
|
170
|
+
errors = vpf.validate(fake_tree)
|
|
171
|
+
assert errors == []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_lore_triggers_empty_list_is_ok(fake_tree):
|
|
175
|
+
vpf = _load_module()
|
|
176
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[]))
|
|
177
|
+
errors = vpf.validate(fake_tree)
|
|
178
|
+
assert errors == []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_lore_triggers_well_formed_passes(fake_tree):
|
|
182
|
+
vpf = _load_module()
|
|
183
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[
|
|
184
|
+
{
|
|
185
|
+
"keys": ["本来无一物"],
|
|
186
|
+
"content": "x" * 100,
|
|
187
|
+
"source_ref": "T48n2008#般若品",
|
|
188
|
+
}
|
|
189
|
+
]))
|
|
190
|
+
errors = vpf.validate(fake_tree)
|
|
191
|
+
assert errors == []
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_lore_triggers_wrong_type_fails(fake_tree):
|
|
195
|
+
vpf = _load_module()
|
|
196
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers="not-a-list"))
|
|
197
|
+
errors = vpf.validate(fake_tree)
|
|
198
|
+
assert any("lore_triggers" in e for e in errors)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_lore_triggers_entry_not_dict_fails(fake_tree):
|
|
202
|
+
vpf = _load_module()
|
|
203
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=["nope"]))
|
|
204
|
+
errors = vpf.validate(fake_tree)
|
|
205
|
+
assert any("lore_triggers" in e for e in errors)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_lore_triggers_missing_required_fails(fake_tree):
|
|
209
|
+
vpf = _load_module()
|
|
210
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[
|
|
211
|
+
{"keys": ["a"], "content": "x" * 100}
|
|
212
|
+
]))
|
|
213
|
+
errors = vpf.validate(fake_tree)
|
|
214
|
+
assert any("source_ref" in e for e in errors)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_lore_triggers_empty_keys_fails(fake_tree):
|
|
218
|
+
vpf = _load_module()
|
|
219
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[
|
|
220
|
+
{"keys": [], "content": "x" * 100, "source_ref": "T48n2008"}
|
|
221
|
+
]))
|
|
222
|
+
errors = vpf.validate(fake_tree)
|
|
223
|
+
assert any("keys" in e for e in errors)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_lore_triggers_content_too_short_fails(fake_tree):
|
|
227
|
+
vpf = _load_module()
|
|
228
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[
|
|
229
|
+
{"keys": ["a"], "content": "短", "source_ref": "T48n2008"}
|
|
230
|
+
]))
|
|
231
|
+
errors = vpf.validate(fake_tree)
|
|
232
|
+
assert any("content" in e and "80" in e for e in errors)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_lore_triggers_content_too_long_fails(fake_tree):
|
|
236
|
+
vpf = _load_module()
|
|
237
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[
|
|
238
|
+
{"keys": ["a"], "content": "x" * 400, "source_ref": "T48n2008"}
|
|
239
|
+
]))
|
|
240
|
+
errors = vpf.validate(fake_tree)
|
|
241
|
+
assert any("content" in e and "300" in e for e in errors)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_lore_triggers_source_ref_not_in_sources_fails(fake_tree):
|
|
245
|
+
vpf = _load_module()
|
|
246
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[
|
|
247
|
+
{"keys": ["a"], "content": "x" * 100, "source_ref": "T99n9999"}
|
|
248
|
+
]))
|
|
249
|
+
errors = vpf.validate(fake_tree)
|
|
250
|
+
assert any("source_ref" in e and "T99n9999" in e for e in errors)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_lore_triggers_source_ref_with_anchor_resolves(fake_tree):
|
|
254
|
+
vpf = _load_module()
|
|
255
|
+
# `T48n2008#般若品` should still resolve to source id T48n2008.
|
|
256
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[
|
|
257
|
+
{"keys": ["a"], "content": "x" * 100, "source_ref": "T48n2008#般若品"}
|
|
258
|
+
]))
|
|
259
|
+
errors = vpf.validate(fake_tree)
|
|
260
|
+
assert errors == []
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_lore_triggers_secondary_keys_requires_selective(fake_tree):
|
|
264
|
+
vpf = _load_module()
|
|
265
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[
|
|
266
|
+
{
|
|
267
|
+
"keys": ["a"],
|
|
268
|
+
"secondary_keys": ["b"],
|
|
269
|
+
"content": "x" * 100,
|
|
270
|
+
"source_ref": "T48n2008",
|
|
271
|
+
# selective missing/false → must error
|
|
272
|
+
}
|
|
273
|
+
]))
|
|
274
|
+
errors = vpf.validate(fake_tree)
|
|
275
|
+
assert any("secondary_keys" in e and "selective" in e for e in errors)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_lore_triggers_secondary_keys_with_selective_passes(fake_tree):
|
|
279
|
+
vpf = _load_module()
|
|
280
|
+
_write_master(fake_tree, "huineng", _base_meta(lore_triggers=[
|
|
281
|
+
{
|
|
282
|
+
"keys": ["a"],
|
|
283
|
+
"secondary_keys": ["b"],
|
|
284
|
+
"content": "x" * 100,
|
|
285
|
+
"source_ref": "T48n2008",
|
|
286
|
+
"selective": True,
|
|
287
|
+
}
|
|
288
|
+
]))
|
|
289
|
+
errors = vpf.validate(fake_tree)
|
|
290
|
+
assert errors == []
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------- meta-skill exclusion ----------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_meta_skill_without_metajson_is_skipped(fake_tree, tmp_path):
|
|
297
|
+
vpf = _load_module()
|
|
298
|
+
# No meta.json under master-curriculum → validator should not blow up
|
|
299
|
+
(fake_tree / "master-curriculum").mkdir(parents=True)
|
|
300
|
+
_write_master(fake_tree, "huineng", _base_meta())
|
|
301
|
+
errors = vpf.validate(fake_tree)
|
|
302
|
+
assert errors == []
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_meta_skill_with_kind_marker_is_skipped(fake_tree):
|
|
306
|
+
"""master-debate carries a protocol meta.json with kind=meta-skill —
|
|
307
|
+
must not be required to declare persona fields."""
|
|
308
|
+
vpf = _load_module()
|
|
309
|
+
_write_master(fake_tree, "debate", {
|
|
310
|
+
"slug": "debate",
|
|
311
|
+
"kind": "meta-skill",
|
|
312
|
+
"debate_protocol": {"default_rounds": 4},
|
|
313
|
+
})
|
|
314
|
+
_write_master(fake_tree, "huineng", _base_meta())
|
|
315
|
+
errors = vpf.validate(fake_tree)
|
|
316
|
+
assert errors == []
|
|
317
|
+
assert not any("debate" in e for e in errors)
|