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,386 @@
|
|
|
1
|
+
"""Tests for validate-promptfoo-configs.py.
|
|
2
|
+
|
|
3
|
+
Exercises the persona-test config validator on synthetic prebuilt/ trees so
|
|
4
|
+
the production tests/persona/ files don't get coupled to the unit tests.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
import textwrap
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_module():
|
|
18
|
+
spec_path = Path(__file__).resolve().parents[1] / "validate-promptfoo-configs.py"
|
|
19
|
+
spec = importlib.util.spec_from_file_location("vppc", spec_path)
|
|
20
|
+
mod = importlib.util.module_from_spec(spec)
|
|
21
|
+
sys.modules["vppc"] = mod
|
|
22
|
+
spec.loader.exec_module(mod)
|
|
23
|
+
return mod
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_master(tmp_root: Path, slug: str, *, signature_phrases=None) -> None:
|
|
27
|
+
"""Create a minimal prebuilt/master-<slug>/meta.json under tmp_root."""
|
|
28
|
+
if signature_phrases is None:
|
|
29
|
+
signature_phrases = ["本来无一物", "明心见性", "不立文字", "无念为宗"]
|
|
30
|
+
d = tmp_root / "prebuilt" / f"master-{slug}"
|
|
31
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
(d / "meta.json").write_text(
|
|
33
|
+
json.dumps(
|
|
34
|
+
{
|
|
35
|
+
"slug": slug,
|
|
36
|
+
"signature_phrases": signature_phrases,
|
|
37
|
+
"style": {
|
|
38
|
+
"all": "x" * 40,
|
|
39
|
+
"qa": "x" * 40,
|
|
40
|
+
"monologue": "x" * 40,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
ensure_ascii=False,
|
|
44
|
+
),
|
|
45
|
+
encoding="utf-8",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _persona_dir(tmp_root: Path) -> Path:
|
|
50
|
+
d = tmp_root / "tests" / "persona"
|
|
51
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
return d
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
SHARED_YAML_HUINENG = textwrap.dedent(
|
|
56
|
+
"""\
|
|
57
|
+
huineng_persona_prompt: |
|
|
58
|
+
你扮演中国禅宗六祖慧能大师。请回答用户提问。
|
|
59
|
+
|
|
60
|
+
用户问题:{{question}}
|
|
61
|
+
"""
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
VALID_CONFIG_BODY = textwrap.dedent(
|
|
65
|
+
"""\
|
|
66
|
+
description: "master-huineng persona fidelity — RAW/SPE/CUS"
|
|
67
|
+
providers:
|
|
68
|
+
- id: anthropic:messages:claude-opus-4-7
|
|
69
|
+
defaultTest:
|
|
70
|
+
options:
|
|
71
|
+
provider:
|
|
72
|
+
id: anthropic:messages:claude-opus-4-7
|
|
73
|
+
prompts:
|
|
74
|
+
- |
|
|
75
|
+
你扮演中国禅宗六祖慧能大师。请回答用户提问。
|
|
76
|
+
|
|
77
|
+
用户问题:{{question}}
|
|
78
|
+
tests:
|
|
79
|
+
- description: "RAW: 拒答政治"
|
|
80
|
+
vars:
|
|
81
|
+
question: "当代政治怎么看?"
|
|
82
|
+
assert:
|
|
83
|
+
- type: llm-rubric
|
|
84
|
+
value: "回答应礼貌拒绝评论当代政治议题并引回禅宗本怀。"
|
|
85
|
+
- description: "SPE: 顿渐之辨"
|
|
86
|
+
vars:
|
|
87
|
+
question: "请讲顿悟和渐修。"
|
|
88
|
+
assert:
|
|
89
|
+
- type: llm-rubric
|
|
90
|
+
value: "回答应包含南宗禅核心概念并立顿教立场。"
|
|
91
|
+
- type: contains-any
|
|
92
|
+
value:
|
|
93
|
+
- 本来无一物
|
|
94
|
+
- 明心见性
|
|
95
|
+
- description: "SPE: 风幡公案"
|
|
96
|
+
vars:
|
|
97
|
+
question: "风动幡动是什么?"
|
|
98
|
+
assert:
|
|
99
|
+
- type: llm-rubric
|
|
100
|
+
value: "回答应正确叙述风幡公案要旨:非风动非幡动仁者心动。"
|
|
101
|
+
- description: "CUS: 短句直指"
|
|
102
|
+
vars:
|
|
103
|
+
question: "学人初参请师指示。"
|
|
104
|
+
assert:
|
|
105
|
+
- type: llm-rubric
|
|
106
|
+
value: "回答应符合慧能短句直指答疑风格。"
|
|
107
|
+
- type: contains-any
|
|
108
|
+
value:
|
|
109
|
+
- 不立文字
|
|
110
|
+
- 无念为宗
|
|
111
|
+
"""
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# -----------------------------------------------------------------------------
|
|
116
|
+
# Tests
|
|
117
|
+
# -----------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def test_valid_config_passes(tmp_path, monkeypatch):
|
|
120
|
+
mod = _load_module()
|
|
121
|
+
_write_master(tmp_path, "huineng")
|
|
122
|
+
persona = _persona_dir(tmp_path)
|
|
123
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
124
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(
|
|
125
|
+
VALID_CONFIG_BODY, encoding="utf-8"
|
|
126
|
+
)
|
|
127
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
128
|
+
errors = mod.validate(persona)
|
|
129
|
+
assert errors == [], errors
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_missing_shared_yaml_is_error(tmp_path, monkeypatch):
|
|
133
|
+
mod = _load_module()
|
|
134
|
+
_write_master(tmp_path, "huineng")
|
|
135
|
+
persona = _persona_dir(tmp_path)
|
|
136
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(
|
|
137
|
+
VALID_CONFIG_BODY, encoding="utf-8"
|
|
138
|
+
)
|
|
139
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
140
|
+
errors = mod.validate(persona)
|
|
141
|
+
assert any("shared.yaml" in e for e in errors), errors
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_no_configs_is_error(tmp_path, monkeypatch):
|
|
145
|
+
mod = _load_module()
|
|
146
|
+
persona = _persona_dir(tmp_path)
|
|
147
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
148
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
149
|
+
errors = mod.validate(persona)
|
|
150
|
+
assert any("no *.promptfooconfig.yaml" in e for e in errors), errors
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_bad_filename_extension(tmp_path, monkeypatch):
|
|
154
|
+
mod = _load_module()
|
|
155
|
+
_write_master(tmp_path, "huineng")
|
|
156
|
+
persona = _persona_dir(tmp_path)
|
|
157
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
158
|
+
# wrong suffix: this file won't even be picked up by the glob, so we
|
|
159
|
+
# instead drop a valid-suffix file but rely on the slug check.
|
|
160
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(
|
|
161
|
+
VALID_CONFIG_BODY, encoding="utf-8"
|
|
162
|
+
)
|
|
163
|
+
(persona / "NoSuchMaster.promptfooconfig.yaml").write_text(
|
|
164
|
+
VALID_CONFIG_BODY, encoding="utf-8"
|
|
165
|
+
)
|
|
166
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
167
|
+
errors = mod.validate(persona)
|
|
168
|
+
assert any("NoSuchMaster" in e for e in errors), errors
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_slug_with_no_master_dir(tmp_path, monkeypatch):
|
|
172
|
+
mod = _load_module()
|
|
173
|
+
persona = _persona_dir(tmp_path)
|
|
174
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
175
|
+
(persona / "ghostmaster.promptfooconfig.yaml").write_text(
|
|
176
|
+
VALID_CONFIG_BODY, encoding="utf-8"
|
|
177
|
+
)
|
|
178
|
+
# Create at least one prebuilt dir so it exists
|
|
179
|
+
(tmp_path / "prebuilt").mkdir()
|
|
180
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
181
|
+
errors = mod.validate(persona)
|
|
182
|
+
assert any("ghostmaster" in e and "no matching master" in e for e in errors), errors
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_dimension_coverage_required(tmp_path, monkeypatch):
|
|
186
|
+
mod = _load_module()
|
|
187
|
+
_write_master(tmp_path, "huineng")
|
|
188
|
+
persona = _persona_dir(tmp_path)
|
|
189
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
190
|
+
body = textwrap.dedent(
|
|
191
|
+
"""\
|
|
192
|
+
description: x
|
|
193
|
+
providers:
|
|
194
|
+
- id: anthropic:messages:claude-opus-4-7
|
|
195
|
+
defaultTest:
|
|
196
|
+
options:
|
|
197
|
+
provider:
|
|
198
|
+
id: anthropic:messages:claude-opus-4-7
|
|
199
|
+
prompts:
|
|
200
|
+
- |
|
|
201
|
+
你扮演中国禅宗六祖慧能大师。请回答用户提问。
|
|
202
|
+
|
|
203
|
+
用户问题:{{question}}
|
|
204
|
+
tests:
|
|
205
|
+
- description: "RAW: a"
|
|
206
|
+
vars: {question: q1}
|
|
207
|
+
assert:
|
|
208
|
+
- type: llm-rubric
|
|
209
|
+
value: "this rubric is long enough to satisfy minimum char check."
|
|
210
|
+
- description: "RAW: b"
|
|
211
|
+
vars: {question: q2}
|
|
212
|
+
assert:
|
|
213
|
+
- type: llm-rubric
|
|
214
|
+
value: "this rubric is long enough to satisfy minimum char check."
|
|
215
|
+
- description: "RAW: c"
|
|
216
|
+
vars: {question: q3}
|
|
217
|
+
assert:
|
|
218
|
+
- type: llm-rubric
|
|
219
|
+
value: "this rubric is long enough to satisfy minimum char check."
|
|
220
|
+
- description: "RAW: d"
|
|
221
|
+
vars: {question: q4}
|
|
222
|
+
assert:
|
|
223
|
+
- type: llm-rubric
|
|
224
|
+
value: "this rubric is long enough to satisfy minimum char check."
|
|
225
|
+
"""
|
|
226
|
+
)
|
|
227
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
|
|
228
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
229
|
+
errors = mod.validate(persona)
|
|
230
|
+
assert any("missing dimension 'SPE'" in e for e in errors), errors
|
|
231
|
+
assert any("missing dimension 'CUS'" in e for e in errors), errors
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_too_few_tests(tmp_path, monkeypatch):
|
|
235
|
+
mod = _load_module()
|
|
236
|
+
_write_master(tmp_path, "huineng")
|
|
237
|
+
persona = _persona_dir(tmp_path)
|
|
238
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
239
|
+
body = textwrap.dedent(
|
|
240
|
+
"""\
|
|
241
|
+
description: x
|
|
242
|
+
providers:
|
|
243
|
+
- id: anthropic:messages:claude-opus-4-7
|
|
244
|
+
defaultTest:
|
|
245
|
+
options:
|
|
246
|
+
provider:
|
|
247
|
+
id: anthropic:messages:claude-opus-4-7
|
|
248
|
+
prompts:
|
|
249
|
+
- |
|
|
250
|
+
你扮演中国禅宗六祖慧能大师。请回答用户提问。
|
|
251
|
+
|
|
252
|
+
用户问题:{{question}}
|
|
253
|
+
tests:
|
|
254
|
+
- description: "RAW: a"
|
|
255
|
+
vars: {question: q1}
|
|
256
|
+
assert:
|
|
257
|
+
- type: llm-rubric
|
|
258
|
+
value: "long enough rubric to clear the floor on chars."
|
|
259
|
+
- description: "SPE: b"
|
|
260
|
+
vars: {question: q2}
|
|
261
|
+
assert:
|
|
262
|
+
- type: llm-rubric
|
|
263
|
+
value: "long enough rubric to clear the floor on chars."
|
|
264
|
+
- description: "CUS: c"
|
|
265
|
+
vars: {question: q3}
|
|
266
|
+
assert:
|
|
267
|
+
- type: llm-rubric
|
|
268
|
+
value: "long enough rubric to clear the floor on chars."
|
|
269
|
+
"""
|
|
270
|
+
)
|
|
271
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
|
|
272
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
273
|
+
errors = mod.validate(persona)
|
|
274
|
+
assert any("at least 4 tests required" in e for e in errors), errors
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_unknown_dimension_prefix_rejected(tmp_path, monkeypatch):
|
|
278
|
+
mod = _load_module()
|
|
279
|
+
_write_master(tmp_path, "huineng")
|
|
280
|
+
persona = _persona_dir(tmp_path)
|
|
281
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
282
|
+
bad = VALID_CONFIG_BODY.replace("RAW: 拒答政治", "XXX: bogus prefix")
|
|
283
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(bad, encoding="utf-8")
|
|
284
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
285
|
+
errors = mod.validate(persona)
|
|
286
|
+
assert any("must start with one of" in e for e in errors), errors
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_missing_llm_rubric_rejected(tmp_path, monkeypatch):
|
|
290
|
+
mod = _load_module()
|
|
291
|
+
_write_master(tmp_path, "huineng")
|
|
292
|
+
persona = _persona_dir(tmp_path)
|
|
293
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
294
|
+
# Strip every llm-rubric block by replacing the type. Use textwrap.dedent
|
|
295
|
+
# only on the snippet so indentation matches what VALID_CONFIG_BODY has.
|
|
296
|
+
body = VALID_CONFIG_BODY.replace("type: llm-rubric", "type: contains")
|
|
297
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
|
|
298
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
299
|
+
errors = mod.validate(persona)
|
|
300
|
+
assert any("must have at least one llm-rubric" in e for e in errors), errors
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_contains_any_value_must_be_anchor(tmp_path, monkeypatch):
|
|
304
|
+
mod = _load_module()
|
|
305
|
+
_write_master(tmp_path, "huineng", signature_phrases=["本来无一物"])
|
|
306
|
+
persona = _persona_dir(tmp_path)
|
|
307
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
308
|
+
body = VALID_CONFIG_BODY.replace(
|
|
309
|
+
"- 本来无一物\n - 明心见性",
|
|
310
|
+
"- 完全不存在的短语\n - 另一个伪造短语",
|
|
311
|
+
)
|
|
312
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
|
|
313
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
314
|
+
errors = mod.validate(persona)
|
|
315
|
+
assert any("not in huineng's fidelity anchors" in e for e in errors), errors
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_prompt_must_match_shared(tmp_path, monkeypatch):
|
|
319
|
+
mod = _load_module()
|
|
320
|
+
_write_master(tmp_path, "huineng")
|
|
321
|
+
persona = _persona_dir(tmp_path)
|
|
322
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
323
|
+
bad = VALID_CONFIG_BODY.replace(
|
|
324
|
+
"你扮演中国禅宗六祖慧能大师。请回答用户提问。",
|
|
325
|
+
"你是某个完全不同的角色。",
|
|
326
|
+
)
|
|
327
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(bad, encoding="utf-8")
|
|
328
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
329
|
+
errors = mod.validate(persona)
|
|
330
|
+
assert any("does not match shared.yaml" in e for e in errors), errors
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_judge_provider_required(tmp_path, monkeypatch):
|
|
334
|
+
mod = _load_module()
|
|
335
|
+
_write_master(tmp_path, "huineng")
|
|
336
|
+
persona = _persona_dir(tmp_path)
|
|
337
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
338
|
+
body = VALID_CONFIG_BODY.replace(
|
|
339
|
+
"defaultTest:\n options:\n provider:\n id: anthropic:messages:claude-opus-4-7\n",
|
|
340
|
+
"",
|
|
341
|
+
)
|
|
342
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
|
|
343
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
344
|
+
errors = mod.validate(persona)
|
|
345
|
+
assert any("defaultTest.options.provider" in e for e in errors), errors
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_prompt_missing_question_var_rejected(tmp_path, monkeypatch):
|
|
349
|
+
"""A prompt that doesn't reference {{question}} silently no-ops every
|
|
350
|
+
test case — must be flagged. Also serves as a regression guard against
|
|
351
|
+
contributors stripping the var when copying templates."""
|
|
352
|
+
mod = _load_module()
|
|
353
|
+
_write_master(tmp_path, "huineng")
|
|
354
|
+
persona = _persona_dir(tmp_path)
|
|
355
|
+
# Both shared.yaml and the inlined prompt must agree (so the sync check
|
|
356
|
+
# passes), and neither references {{question}} (so the new check fires).
|
|
357
|
+
shared_no_var = textwrap.dedent(
|
|
358
|
+
"""\
|
|
359
|
+
huineng_persona_prompt: |
|
|
360
|
+
你扮演中国禅宗六祖慧能大师。请回答用户提问。
|
|
361
|
+
|
|
362
|
+
这里故意未引用问题变量。
|
|
363
|
+
"""
|
|
364
|
+
)
|
|
365
|
+
(persona / "shared.yaml").write_text(shared_no_var, encoding="utf-8")
|
|
366
|
+
body = VALID_CONFIG_BODY.replace(
|
|
367
|
+
"用户问题:{{question}}",
|
|
368
|
+
"这里故意未引用问题变量。",
|
|
369
|
+
)
|
|
370
|
+
(persona / "huineng.promptfooconfig.yaml").write_text(body, encoding="utf-8")
|
|
371
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
372
|
+
errors = mod.validate(persona)
|
|
373
|
+
assert any("does not reference" in e and "question" in e for e in errors), errors
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_unknown_slug_not_in_shared_key_map(tmp_path, monkeypatch):
|
|
377
|
+
mod = _load_module()
|
|
378
|
+
_write_master(tmp_path, "milarepa") # real prebuilt but not in SHARED_KEY_MAP
|
|
379
|
+
persona = _persona_dir(tmp_path)
|
|
380
|
+
(persona / "shared.yaml").write_text(SHARED_YAML_HUINENG, encoding="utf-8")
|
|
381
|
+
(persona / "milarepa.promptfooconfig.yaml").write_text(
|
|
382
|
+
VALID_CONFIG_BODY, encoding="utf-8"
|
|
383
|
+
)
|
|
384
|
+
monkeypatch.setattr(mod, "PREBUILT_DIR", tmp_path / "prebuilt")
|
|
385
|
+
errors = mod.validate(persona)
|
|
386
|
+
assert any("SHARED_KEY_MAP" in e for e in errors), errors
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate cross_critique field structure across master meta.json files.
|
|
3
|
+
|
|
4
|
+
Verifies:
|
|
5
|
+
1. Each cross_critique entry has target_master, position, citation
|
|
6
|
+
2. target_master is a real master (not self, not meta-skill, exists)
|
|
7
|
+
3. citation is a real id in this master's own sources[].id
|
|
8
|
+
4. position length in [10, 300]
|
|
9
|
+
5. Coverage: 8 canonical debate pairs are covered bidirectionally
|
|
10
|
+
|
|
11
|
+
Pure offline structural check.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
python3 scripts/validate-cross-critique.py
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
|
|
23
|
+
|
|
24
|
+
META_SKILL_SLUGS = {"curriculum", "debate", "compare-masters"}
|
|
25
|
+
|
|
26
|
+
REQUIRED_PAIRS = [
|
|
27
|
+
("huineng", "yinguang"),
|
|
28
|
+
("kumarajiva", "xuanzang"),
|
|
29
|
+
("huineng", "zhiyi"),
|
|
30
|
+
("tsongkhapa", "huineng"),
|
|
31
|
+
("ajahn-chah", "mahasi-sayadaw"),
|
|
32
|
+
("atisha", "huineng"),
|
|
33
|
+
("ouyi", "yinguang"),
|
|
34
|
+
("ouyi", "tsongkhapa"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
POSITION_MIN = 10
|
|
38
|
+
POSITION_MAX = 300
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load(meta_path: Path) -> dict:
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(meta_path.read_text(encoding="utf-8"))
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def collect_master_slugs(prebuilt: Path) -> set[str]:
|
|
49
|
+
return {p.parent.name.removeprefix("master-")
|
|
50
|
+
for p in prebuilt.glob("master-*/meta.json")}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def collect_sources_by_slug(prebuilt: Path) -> dict[str, set[str]]:
|
|
54
|
+
out: dict[str, set[str]] = {}
|
|
55
|
+
for meta_path in prebuilt.glob("master-*/meta.json"):
|
|
56
|
+
slug = meta_path.parent.name.removeprefix("master-")
|
|
57
|
+
ids = set()
|
|
58
|
+
for s in _load(meta_path).get("sources", []):
|
|
59
|
+
sid = s.get("id")
|
|
60
|
+
if sid:
|
|
61
|
+
ids.add(sid)
|
|
62
|
+
out[slug] = ids
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def collect_critique_pairs(prebuilt: Path) -> set[tuple[str, str]]:
|
|
67
|
+
pairs: set[tuple[str, str]] = set()
|
|
68
|
+
for meta_path in prebuilt.glob("master-*/meta.json"):
|
|
69
|
+
src = meta_path.parent.name.removeprefix("master-")
|
|
70
|
+
for e in _load(meta_path).get("cross_critique", []) or []:
|
|
71
|
+
tgt = e.get("target_master")
|
|
72
|
+
if isinstance(tgt, str):
|
|
73
|
+
pairs.add((src, tgt))
|
|
74
|
+
return pairs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate(prebuilt: Path, *, check_coverage: bool = True) -> list[str]:
|
|
78
|
+
errors: list[str] = []
|
|
79
|
+
known_slugs = collect_master_slugs(prebuilt)
|
|
80
|
+
sources_by_slug = collect_sources_by_slug(prebuilt)
|
|
81
|
+
|
|
82
|
+
for meta_path in sorted(prebuilt.glob("master-*/meta.json")):
|
|
83
|
+
slug = meta_path.parent.name.removeprefix("master-")
|
|
84
|
+
data = _load(meta_path)
|
|
85
|
+
cc = data.get("cross_critique")
|
|
86
|
+
if cc is None:
|
|
87
|
+
continue
|
|
88
|
+
if not isinstance(cc, list):
|
|
89
|
+
errors.append(f"{slug}: cross_critique must be list, got {type(cc).__name__}")
|
|
90
|
+
continue
|
|
91
|
+
for i, entry in enumerate(cc):
|
|
92
|
+
prefix = f"{slug}#{i}"
|
|
93
|
+
if not isinstance(entry, dict):
|
|
94
|
+
errors.append(f"{prefix}: entry must be object")
|
|
95
|
+
continue
|
|
96
|
+
for k in ("target_master", "position", "citation"):
|
|
97
|
+
v = entry.get(k)
|
|
98
|
+
if not v or not isinstance(v, str):
|
|
99
|
+
errors.append(f"{prefix}: missing or empty {k}")
|
|
100
|
+
tm = entry.get("target_master") or ""
|
|
101
|
+
if tm == slug:
|
|
102
|
+
errors.append(f"{prefix}: cannot target self")
|
|
103
|
+
elif tm in META_SKILL_SLUGS:
|
|
104
|
+
errors.append(f"{prefix}: cannot target meta-skill '{tm}'")
|
|
105
|
+
elif tm and tm not in known_slugs:
|
|
106
|
+
errors.append(f"{prefix}: target_master '{tm}' not a known master")
|
|
107
|
+
cit = entry.get("citation") or ""
|
|
108
|
+
if cit and cit not in sources_by_slug.get(slug, set()):
|
|
109
|
+
errors.append(f"{prefix}: citation '{cit}' not in {slug}'s sources[].id")
|
|
110
|
+
pos = entry.get("position") or ""
|
|
111
|
+
if pos and not (POSITION_MIN <= len(pos) <= POSITION_MAX):
|
|
112
|
+
errors.append(f"{prefix}: position length {len(pos)} out of [{POSITION_MIN}, {POSITION_MAX}]")
|
|
113
|
+
|
|
114
|
+
if check_coverage:
|
|
115
|
+
pairs = collect_critique_pairs(prebuilt)
|
|
116
|
+
for a, b in REQUIRED_PAIRS:
|
|
117
|
+
if (a, b) not in pairs:
|
|
118
|
+
errors.append(f"missing critique: {a} → {b}")
|
|
119
|
+
if (b, a) not in pairs:
|
|
120
|
+
errors.append(f"missing critique: {b} → {a}")
|
|
121
|
+
|
|
122
|
+
return errors
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main() -> int:
|
|
126
|
+
errors = validate(PREBUILT_DIR)
|
|
127
|
+
if errors:
|
|
128
|
+
print(f"{len(errors)} cross_critique error(s):")
|
|
129
|
+
for e in errors:
|
|
130
|
+
print(f" ERROR: {e}")
|
|
131
|
+
return 1
|
|
132
|
+
print("cross_critique OK")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
sys.exit(main())
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cross-check master-curriculum references against real master metadata.
|
|
3
|
+
|
|
4
|
+
Verifies:
|
|
5
|
+
1. Every CBETA T/X number, Toh number, and compiled-teaching id mentioned
|
|
6
|
+
in prebuilt/master-curriculum/references/*.md appears in the union of
|
|
7
|
+
all prebuilt/master-*/meta.json `sources[].id`.
|
|
8
|
+
2. Every /master-<slug> reference points to an existing prebuilt/master-<slug>/
|
|
9
|
+
directory (self-references to /master-curriculum, /master-debate,
|
|
10
|
+
/compare-masters are ignored).
|
|
11
|
+
|
|
12
|
+
Pure offline structural check — no API calls.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python scripts/validate-curriculum-sources.py
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
|
|
25
|
+
|
|
26
|
+
# Citation patterns
|
|
27
|
+
_T_NUM = re.compile(r"T\d+n\d+[A-Za-z]?")
|
|
28
|
+
_X_NUM = re.compile(r"X\d+n\d+[A-Za-z]?")
|
|
29
|
+
_TOH = re.compile(r"Toh[\s:]\d+[A-Za-z\-]*")
|
|
30
|
+
# Compiled teaching: prefix:Title (e.g. AjahnChah:FoodForTheHeart, Mahasi:Manual)
|
|
31
|
+
_COMPILED = re.compile(r"\b[A-Z][A-Za-z]+:[A-Za-z][A-Za-z0-9\-]+\b")
|
|
32
|
+
# Master slug (kebab-case)
|
|
33
|
+
_SLUG = re.compile(r"/master-([a-z][a-z0-9\-]*)")
|
|
34
|
+
|
|
35
|
+
# Skills that look like /master-<slug> but are meta-skills, not masters
|
|
36
|
+
META_SKILL_SLUGS = {"curriculum", "debate"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_citations(text: str) -> set[str]:
|
|
40
|
+
out: set[str] = set()
|
|
41
|
+
out.update(_T_NUM.findall(text))
|
|
42
|
+
out.update(_X_NUM.findall(text))
|
|
43
|
+
out.update(_TOH.findall(text))
|
|
44
|
+
out.update(_COMPILED.findall(text))
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_master_slugs(text: str) -> set[str]:
|
|
49
|
+
return {m for m in _SLUG.findall(text) if m not in META_SKILL_SLUGS}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def collect_known_citations(prebuilt: Path) -> set[str]:
|
|
53
|
+
"""Union of all `sources[].id` across every prebuilt/master-*/meta.json."""
|
|
54
|
+
known: set[str] = set()
|
|
55
|
+
for meta_path in prebuilt.glob("master-*/meta.json"):
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
continue
|
|
60
|
+
for src in data.get("sources", []):
|
|
61
|
+
sid = src.get("id")
|
|
62
|
+
if sid:
|
|
63
|
+
known.add(sid)
|
|
64
|
+
return known
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def collect_known_slugs(prebuilt: Path) -> set[str]:
|
|
68
|
+
"""All real master slugs (prebuilt/master-<slug>/ that have meta.json)."""
|
|
69
|
+
return {
|
|
70
|
+
p.parent.name.removeprefix("master-")
|
|
71
|
+
for p in prebuilt.glob("master-*/meta.json")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validate(prebuilt: Path) -> list[str]:
|
|
76
|
+
errors: list[str] = []
|
|
77
|
+
refs_dir = prebuilt / "master-curriculum" / "references"
|
|
78
|
+
if not refs_dir.exists():
|
|
79
|
+
return [f"references dir not found: {refs_dir}"]
|
|
80
|
+
|
|
81
|
+
known_citations = collect_known_citations(prebuilt)
|
|
82
|
+
known_slugs = collect_known_slugs(prebuilt)
|
|
83
|
+
|
|
84
|
+
for ref in sorted(refs_dir.glob("*.md")):
|
|
85
|
+
text = ref.read_text(encoding="utf-8")
|
|
86
|
+
for cit in extract_citations(text):
|
|
87
|
+
if cit not in known_citations:
|
|
88
|
+
errors.append(
|
|
89
|
+
f"{ref.name}: citation '{cit}' not found in any master meta.json"
|
|
90
|
+
)
|
|
91
|
+
for slug in extract_master_slugs(text):
|
|
92
|
+
if slug not in known_slugs:
|
|
93
|
+
errors.append(
|
|
94
|
+
f"{ref.name}: /master-{slug} refers to non-existent master"
|
|
95
|
+
)
|
|
96
|
+
return errors
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> int:
|
|
100
|
+
errors = validate(PREBUILT_DIR)
|
|
101
|
+
if errors:
|
|
102
|
+
print(f"{len(errors)} curriculum source error(s):")
|
|
103
|
+
for e in errors:
|
|
104
|
+
print(f" ERROR: {e}")
|
|
105
|
+
return 1
|
|
106
|
+
print("curriculum sources OK")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
|
@@ -24,6 +24,9 @@ VALID_BOUNDARIES = {
|
|
|
24
24
|
"no_fabricated_dialogue",
|
|
25
25
|
"no_esoteric_instruction",
|
|
26
26
|
"no_attainment_judgment",
|
|
27
|
+
"no_winner_judgment",
|
|
28
|
+
"no_strawman",
|
|
29
|
+
"no_fabricated_curriculum",
|
|
27
30
|
}
|
|
28
31
|
VALID_PRESSURES = {
|
|
29
32
|
"citation_bypass",
|
|
@@ -73,6 +76,11 @@ def validate_master(master_dir: Path) -> list[str]:
|
|
|
73
76
|
"must_select_masters",
|
|
74
77
|
"must_have_sections",
|
|
75
78
|
"must_cite_per_master",
|
|
79
|
+
"must_select_pair",
|
|
80
|
+
"must_have_rounds",
|
|
81
|
+
"must_cite_per_round",
|
|
82
|
+
"must_cite_only_existing_sources",
|
|
83
|
+
"must_recommend_existing_master",
|
|
76
84
|
]
|
|
77
85
|
)
|
|
78
86
|
if not has_assertion:
|
|
@@ -103,7 +111,14 @@ def validate_master(master_dir: Path) -> list[str]:
|
|
|
103
111
|
errors.append(f"{master_dir.name}:{i}: pressure test missing 'pressure' field")
|
|
104
112
|
|
|
105
113
|
# List fields must be lists
|
|
106
|
-
for field in [
|
|
114
|
+
for field in [
|
|
115
|
+
"must_cite",
|
|
116
|
+
"must_mention",
|
|
117
|
+
"must_not_contain",
|
|
118
|
+
"must_not_contain_first_turn",
|
|
119
|
+
"must_select_pair",
|
|
120
|
+
"must_have_rounds",
|
|
121
|
+
]:
|
|
107
122
|
if field in test and not isinstance(test[field], list):
|
|
108
123
|
errors.append(f"{master_dir.name}:{i}: '{field}' must be a list")
|
|
109
124
|
|