master-skill 0.9.1 → 0.10.1
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/ETHICS.md +23 -17
- package/README.md +57 -14
- package/README_EN.md +60 -16
- package/SKILL.md +5 -5
- package/bin/cli.mjs +497 -62
- package/gemini-extension.json +2 -2
- package/hooks/run-hook.cmd +18 -5
- package/hooks/tests/test_run_hook.sh +114 -0
- package/hooks/tests/test_run_hook_cmd.sh +94 -0
- package/masters/.gitkeep +0 -0
- package/package.json +9 -3
- package/prebuilt/compare/SKILL.md +48 -14
- package/prebuilt/compare/tests/fidelity.jsonl +12 -12
- package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
- package/prebuilt/master-ajahn-chah/meta.json +8 -0
- package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
- package/prebuilt/master-atisha/SKILL.md +13 -11
- package/prebuilt/master-atisha/meta.json +8 -0
- package/prebuilt/master-atisha/references/voice.md +1 -1
- package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
- package/prebuilt/master-buddhaghosa/meta.json +8 -0
- package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
- package/prebuilt/master-fazang/SKILL.md +3 -3
- package/prebuilt/master-fazang/meta.json +8 -0
- package/prebuilt/master-huineng/SKILL.md +3 -3
- package/prebuilt/master-huineng/meta.json +8 -0
- package/prebuilt/master-kumarajiva/SKILL.md +3 -3
- package/prebuilt/master-kumarajiva/meta.json +8 -0
- package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
- package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
- package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
- package/prebuilt/master-milarepa/SKILL.md +13 -11
- package/prebuilt/master-milarepa/meta.json +8 -0
- package/prebuilt/master-milarepa/references/voice.md +1 -1
- package/prebuilt/master-nagarjuna/SKILL.md +3 -3
- package/prebuilt/master-nagarjuna/meta.json +8 -0
- package/prebuilt/master-ouyi/SKILL.md +3 -3
- package/prebuilt/master-ouyi/meta.json +8 -0
- package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
- package/prebuilt/master-tsongkhapa/meta.json +8 -0
- package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
- package/prebuilt/master-xuanzang/SKILL.md +3 -3
- package/prebuilt/master-xuanzang/meta.json +8 -0
- package/prebuilt/master-xuyun/SKILL.md +3 -3
- package/prebuilt/master-xuyun/meta.json +8 -0
- package/prebuilt/master-yinguang/SKILL.md +3 -3
- package/prebuilt/master-yinguang/meta.json +8 -0
- package/prebuilt/master-zhiyi/SKILL.md +3 -3
- package/prebuilt/master-zhiyi/meta.json +8 -0
- package/prompts/correction_handler.md +104 -0
- package/prompts/doctrine_reviewer.md +61 -0
- package/prompts/intake.md +62 -0
- package/prompts/merger.md +62 -0
- package/prompts/rag_instructions.md +54 -0
- package/prompts/sutra_analyzer.md +83 -0
- package/prompts/teaching_builder.md +41 -0
- package/prompts/voice_analyzer.md +92 -0
- package/prompts/voice_builder.md +48 -0
- package/prompts/voice_reviewer.md +66 -0
- package/references/README.md +12 -0
- package/references/ethics-runtime.md +112 -0
- package/references/fojin-api.md +223 -0
- package/references/source-conventions.md +129 -0
- package/references/teaching-modes.md +84 -0
- package/references/traditions.md +72 -0
- package/references/workflow-details.md +361 -0
- package/requirements.txt +6 -0
- package/scripts/select-fidelity-smoke.py +78 -0
- package/scripts/test-fidelity.py +40 -10
- package/scripts/tests/test_select_fidelity_smoke.py +142 -0
- package/scripts/tests/test_validate_citation_contract.py +408 -0
- package/scripts/tests/test_validate_fidelity.py +59 -0
- package/scripts/tests/test_validate_workflow.py +265 -0
- package/scripts/validate-citation-contract.py +193 -0
- package/scripts/validate-fidelity.py +20 -0
- package/scripts/verify_citations.py +8 -1
- package/skill-catalog.json +147 -0
- package/tools/cross_reference.py +365 -0
- package/tools/fojin_bridge.py +146 -0
- package/tools/master_builder.py +341 -0
- package/tools/rag_query.py +336 -0
- package/tools/skill_writer.py +230 -0
- package/tools/sutra_collector.py +237 -0
- package/tools/verify_sources.py +512 -0
- package/tools/version_manager.py +88 -0
package/scripts/test-fidelity.py
CHANGED
|
@@ -138,6 +138,7 @@ def run_tests(
|
|
|
138
138
|
dry_run: bool = False,
|
|
139
139
|
model: str = "claude-sonnet-4-6",
|
|
140
140
|
max_tests: int | None = None,
|
|
141
|
+
quiet: bool = False,
|
|
141
142
|
) -> dict:
|
|
142
143
|
"""Run fidelity tests for a master. Returns summary."""
|
|
143
144
|
master_dir = PREBUILT_DIR / master_name
|
|
@@ -197,7 +198,8 @@ def run_tests(
|
|
|
197
198
|
failed = 0
|
|
198
199
|
|
|
199
200
|
for i, test in enumerate(tests):
|
|
200
|
-
|
|
201
|
+
if not quiet:
|
|
202
|
+
print(f" [{i+1}/{len(tests)}] {test['q'][:50]}...", end=" ", flush=True)
|
|
201
203
|
|
|
202
204
|
try:
|
|
203
205
|
message = client.messages.create(
|
|
@@ -215,7 +217,8 @@ def run_tests(
|
|
|
215
217
|
"error": str(e),
|
|
216
218
|
})
|
|
217
219
|
failed += 1
|
|
218
|
-
|
|
220
|
+
if not quiet:
|
|
221
|
+
print("API ERROR")
|
|
219
222
|
continue
|
|
220
223
|
|
|
221
224
|
check = check_response(
|
|
@@ -240,12 +243,14 @@ def run_tests(
|
|
|
240
243
|
|
|
241
244
|
if check["passed"]:
|
|
242
245
|
passed += 1
|
|
243
|
-
|
|
246
|
+
if not quiet:
|
|
247
|
+
print("PASS")
|
|
244
248
|
else:
|
|
245
249
|
failed += 1
|
|
246
250
|
failures = (check["missing_cites"] + check["missing_mentions"]
|
|
247
251
|
+ check["forbidden_found"] + check["boundary_violations"])
|
|
248
|
-
|
|
252
|
+
if not quiet:
|
|
253
|
+
print(f"FAIL ({failures})")
|
|
249
254
|
|
|
250
255
|
return {
|
|
251
256
|
"master": master_name,
|
|
@@ -258,7 +263,22 @@ def run_tests(
|
|
|
258
263
|
}
|
|
259
264
|
|
|
260
265
|
|
|
261
|
-
def
|
|
266
|
+
def results_failed(results: list[dict], dry_run: bool) -> bool:
|
|
267
|
+
"""Return whether collected fidelity results require a failing exit status."""
|
|
268
|
+
if dry_run:
|
|
269
|
+
return any("error" in suite for suite in results)
|
|
270
|
+
return any(
|
|
271
|
+
"error" in suite
|
|
272
|
+
or suite.get("failed", 0) > 0
|
|
273
|
+
or any(
|
|
274
|
+
case.get("status") in {"FAIL", "api_error"}
|
|
275
|
+
for case in suite.get("results", [])
|
|
276
|
+
)
|
|
277
|
+
for suite in results
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def main() -> int:
|
|
262
282
|
parser = argparse.ArgumentParser(description="Master-skill fidelity test runner")
|
|
263
283
|
parser.add_argument("--master", type=str, help="Test a specific master")
|
|
264
284
|
parser.add_argument("--all", action="store_true", help="Test all masters with fidelity.jsonl")
|
|
@@ -276,6 +296,9 @@ def main():
|
|
|
276
296
|
if not args.master and not args.all:
|
|
277
297
|
parser.error("Specify --master <name> or --all")
|
|
278
298
|
|
|
299
|
+
if args.json and hasattr(sys.stdout, "reconfigure"):
|
|
300
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
301
|
+
|
|
279
302
|
if args.all:
|
|
280
303
|
masters = sorted(
|
|
281
304
|
d.name for d in PREBUILT_DIR.iterdir()
|
|
@@ -286,11 +309,16 @@ def main():
|
|
|
286
309
|
|
|
287
310
|
all_results = []
|
|
288
311
|
for master in masters:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
312
|
+
if not args.json:
|
|
313
|
+
print(f"\n{'='*50}")
|
|
314
|
+
print(f"Testing: {master}")
|
|
315
|
+
print(f"{'='*50}")
|
|
292
316
|
result = run_tests(
|
|
293
|
-
master,
|
|
317
|
+
master,
|
|
318
|
+
dry_run=args.dry_run,
|
|
319
|
+
model=args.model,
|
|
320
|
+
max_tests=args.max_tests,
|
|
321
|
+
quiet=args.json,
|
|
294
322
|
)
|
|
295
323
|
all_results.append(result)
|
|
296
324
|
|
|
@@ -309,6 +337,8 @@ def main():
|
|
|
309
337
|
else:
|
|
310
338
|
print(f" {r['master']}: {r.get('passed', 0)}/{r['total']} ({r.get('pass_rate', 'N/A')})")
|
|
311
339
|
|
|
340
|
+
return 1 if results_failed(all_results, args.dry_run) else 0
|
|
341
|
+
|
|
312
342
|
|
|
313
343
|
if __name__ == "__main__":
|
|
314
|
-
main()
|
|
344
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Behavior tests for deterministic fidelity smoke-target selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
14
|
+
SELECTOR = ROOT / "scripts" / "select-fidelity-smoke.py"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _write_meta(prebuilt: Path, slug: str, sources: object) -> None:
|
|
18
|
+
master_dir = prebuilt / slug
|
|
19
|
+
master_dir.mkdir(parents=True)
|
|
20
|
+
(master_dir / "meta.json").write_text(
|
|
21
|
+
json.dumps({"sources": sources}),
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run_selector(
|
|
27
|
+
prebuilt: Path,
|
|
28
|
+
day_of_year: str,
|
|
29
|
+
changed: str | list[str] | None = None,
|
|
30
|
+
) -> subprocess.CompletedProcess[str]:
|
|
31
|
+
command = [
|
|
32
|
+
sys.executable,
|
|
33
|
+
str(SELECTOR),
|
|
34
|
+
"--prebuilt",
|
|
35
|
+
str(prebuilt),
|
|
36
|
+
"--day-of-year",
|
|
37
|
+
day_of_year,
|
|
38
|
+
]
|
|
39
|
+
if changed is not None:
|
|
40
|
+
changed_values = [changed] if isinstance(changed, str) else changed
|
|
41
|
+
for candidate in changed_values:
|
|
42
|
+
command.extend(["--changed", candidate])
|
|
43
|
+
return subprocess.run(command, text=True, capture_output=True, check=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize(
|
|
47
|
+
("day_of_year", "expected"),
|
|
48
|
+
[("008", "master-gamma"), ("099", "master-alpha"), ("100", "master-beta")],
|
|
49
|
+
)
|
|
50
|
+
def test_rotation_parses_zero_padded_days_as_decimal(
|
|
51
|
+
tmp_path: Path,
|
|
52
|
+
day_of_year: str,
|
|
53
|
+
expected: str,
|
|
54
|
+
):
|
|
55
|
+
prebuilt = tmp_path / "prebuilt"
|
|
56
|
+
_write_meta(prebuilt, "master-gamma", [{"id": "g"}])
|
|
57
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
58
|
+
_write_meta(prebuilt, "master-beta", [{"id": "b"}])
|
|
59
|
+
|
|
60
|
+
result = _run_selector(prebuilt, day_of_year)
|
|
61
|
+
|
|
62
|
+
assert result.returncode == 0, result.stderr
|
|
63
|
+
assert result.stdout == f"{expected}\n"
|
|
64
|
+
assert result.stderr == ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_changed_persona_wins_only_when_in_discovered_roster(tmp_path: Path):
|
|
68
|
+
prebuilt = tmp_path / "prebuilt"
|
|
69
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
70
|
+
_write_meta(prebuilt, "master-beta", [{"id": "b"}])
|
|
71
|
+
|
|
72
|
+
selected = _run_selector(prebuilt, "008", "master-beta")
|
|
73
|
+
rotated = _run_selector(prebuilt, "008", "master-not-discovered")
|
|
74
|
+
|
|
75
|
+
assert selected.returncode == 0, selected.stderr
|
|
76
|
+
assert selected.stdout == "master-beta\n"
|
|
77
|
+
assert rotated.returncode == 0, rotated.stderr
|
|
78
|
+
assert rotated.stdout == "master-alpha\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_meta_skill_before_persona_does_not_hide_changed_persona(tmp_path: Path):
|
|
82
|
+
prebuilt = tmp_path / "prebuilt"
|
|
83
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
84
|
+
_write_meta(prebuilt, "master-beta", [{"id": "b"}])
|
|
85
|
+
|
|
86
|
+
result = _run_selector(
|
|
87
|
+
prebuilt,
|
|
88
|
+
"008",
|
|
89
|
+
["compare", "master-beta", "master-alpha"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert result.returncode == 0, result.stderr
|
|
93
|
+
assert result.stdout == "master-beta\n"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_discovery_ignores_meta_skills_and_empty_sources(tmp_path: Path):
|
|
97
|
+
prebuilt = tmp_path / "prebuilt"
|
|
98
|
+
_write_meta(prebuilt, "compare", [{"id": "meta"}])
|
|
99
|
+
_write_meta(prebuilt, "master-empty", [])
|
|
100
|
+
_write_meta(prebuilt, "master-valid", [{"id": "source"}])
|
|
101
|
+
|
|
102
|
+
result = _run_selector(prebuilt, "100")
|
|
103
|
+
|
|
104
|
+
assert result.returncode == 0, result.stderr
|
|
105
|
+
assert result.stdout == "master-valid\n"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_empty_roster_fails_closed(tmp_path: Path):
|
|
109
|
+
prebuilt = tmp_path / "prebuilt"
|
|
110
|
+
_write_meta(prebuilt, "master-empty", [])
|
|
111
|
+
|
|
112
|
+
result = _run_selector(prebuilt, "008")
|
|
113
|
+
|
|
114
|
+
assert result.returncode != 0
|
|
115
|
+
assert result.stdout == ""
|
|
116
|
+
assert "No persona smoke targets" in result.stderr
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_invalid_json_after_valid_metadata_fails_without_partial_stdout(tmp_path: Path):
|
|
120
|
+
prebuilt = tmp_path / "prebuilt"
|
|
121
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
122
|
+
invalid_dir = prebuilt / "master-zeta"
|
|
123
|
+
invalid_dir.mkdir(parents=True)
|
|
124
|
+
(invalid_dir / "meta.json").write_text("{not-json", encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
result = _run_selector(prebuilt, "008")
|
|
127
|
+
|
|
128
|
+
assert result.returncode != 0
|
|
129
|
+
assert result.stdout == ""
|
|
130
|
+
assert "master-zeta/meta.json" in result.stderr
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize("day_of_year", ["0", "367", "not-a-day"])
|
|
134
|
+
def test_invalid_day_fails_closed(tmp_path: Path, day_of_year: str):
|
|
135
|
+
prebuilt = tmp_path / "prebuilt"
|
|
136
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
137
|
+
|
|
138
|
+
result = _run_selector(prebuilt, day_of_year)
|
|
139
|
+
|
|
140
|
+
assert result.returncode != 0
|
|
141
|
+
assert result.stdout == ""
|
|
142
|
+
assert "day-of-year" in result.stderr
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Tests for the cross-tradition citation contract validator."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def validator():
|
|
14
|
+
script_path = Path(__file__).resolve().parents[1] / "validate-citation-contract.py"
|
|
15
|
+
spec = importlib.util.spec_from_file_location("validate_citation_contract", script_path)
|
|
16
|
+
module = importlib.util.module_from_spec(spec)
|
|
17
|
+
sys.modules["validate_citation_contract"] = module
|
|
18
|
+
spec.loader.exec_module(module)
|
|
19
|
+
return module
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def fake_tree(tmp_path: Path) -> Path:
|
|
24
|
+
return tmp_path / "prebuilt"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def contract(*types: str) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
"version": 1,
|
|
30
|
+
"claim_policy": "declared_sources_only",
|
|
31
|
+
"required_for": [
|
|
32
|
+
"doctrinal_claim",
|
|
33
|
+
"practice_guidance",
|
|
34
|
+
"text_interpretation",
|
|
35
|
+
],
|
|
36
|
+
"allowed_source_types": sorted(types),
|
|
37
|
+
"minimum_claim_coverage": 0.9,
|
|
38
|
+
"live_retrieval_allowed": True,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_meta(
|
|
43
|
+
prebuilt: Path,
|
|
44
|
+
slug: str,
|
|
45
|
+
source_types: list[str],
|
|
46
|
+
value: dict | None,
|
|
47
|
+
*,
|
|
48
|
+
kind: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
directory = prebuilt / f"master-{slug}"
|
|
51
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
data = {
|
|
53
|
+
"slug": slug,
|
|
54
|
+
"sources": [
|
|
55
|
+
{"type": source_type, "id": f"id-{index}", "title": f"source-{index}"}
|
|
56
|
+
for index, source_type in enumerate(source_types)
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
if kind is not None:
|
|
60
|
+
data["kind"] = kind
|
|
61
|
+
if value is not None:
|
|
62
|
+
data["citation_contract"] = value
|
|
63
|
+
(directory / "meta.json").write_text(json.dumps(data), encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize(
|
|
67
|
+
"source_types",
|
|
68
|
+
[
|
|
69
|
+
["cbeta"],
|
|
70
|
+
["tibetan_canon", "tibetan_treatise"],
|
|
71
|
+
["compiled_teaching", "pali_canon", "pali_treatise"],
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
def test_valid_source_family_contracts_pass(validator, fake_tree, source_types):
|
|
75
|
+
write_meta(fake_tree, "demo", source_types, contract(*source_types))
|
|
76
|
+
assert validator.validate(fake_tree) == []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_missing_contract_fails(validator, fake_tree):
|
|
80
|
+
write_meta(fake_tree, "demo", ["cbeta"], None)
|
|
81
|
+
errors = validator.validate(fake_tree)
|
|
82
|
+
assert any(
|
|
83
|
+
"master-demo/meta.json" in error and "citation_contract" in error
|
|
84
|
+
for error in errors
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize("allowed", [[], ["cbeta", "pali_canon"]])
|
|
89
|
+
def test_source_type_drift_fails(validator, fake_tree, allowed):
|
|
90
|
+
value = contract("cbeta")
|
|
91
|
+
value["allowed_source_types"] = allowed
|
|
92
|
+
write_meta(fake_tree, "demo", ["cbeta"], value)
|
|
93
|
+
errors = validator.validate(fake_tree)
|
|
94
|
+
assert any(
|
|
95
|
+
"master-demo/meta.json" in error and "allowed_source_types" in error
|
|
96
|
+
for error in errors
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize(
|
|
101
|
+
("field", "invalid"),
|
|
102
|
+
[
|
|
103
|
+
("version", 2),
|
|
104
|
+
("claim_policy", "any_source"),
|
|
105
|
+
("required_for", ["doctrinal_claim"]),
|
|
106
|
+
("minimum_claim_coverage", True),
|
|
107
|
+
("minimum_claim_coverage", 0.8),
|
|
108
|
+
("live_retrieval_allowed", "yes"),
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
def test_fixed_contract_values_are_enforced(
|
|
112
|
+
validator,
|
|
113
|
+
fake_tree,
|
|
114
|
+
field,
|
|
115
|
+
invalid,
|
|
116
|
+
):
|
|
117
|
+
value = contract("cbeta")
|
|
118
|
+
value[field] = invalid
|
|
119
|
+
write_meta(fake_tree, "demo", ["cbeta"], value)
|
|
120
|
+
errors = validator.validate(fake_tree)
|
|
121
|
+
assert any(
|
|
122
|
+
"master-demo/meta.json" in error and field in error for error in errors
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_meta_skill_without_sources_is_ignored(validator, fake_tree):
|
|
127
|
+
write_meta(fake_tree, "debate", [], None, kind="meta-skill")
|
|
128
|
+
assert validator.validate(fake_tree) == []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_meta_skill_must_not_declare_persona_contract(validator, fake_tree):
|
|
132
|
+
write_meta(
|
|
133
|
+
fake_tree,
|
|
134
|
+
"debate",
|
|
135
|
+
["cbeta"],
|
|
136
|
+
contract("cbeta"),
|
|
137
|
+
kind="meta-skill",
|
|
138
|
+
)
|
|
139
|
+
errors = validator.validate(fake_tree)
|
|
140
|
+
assert any(
|
|
141
|
+
"master-debate/meta.json" in error and "meta-skill" in error
|
|
142
|
+
for error in errors
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_persona_without_sources_is_not_treated_as_meta_skill(validator, fake_tree):
|
|
147
|
+
write_meta(fake_tree, "demo", [], None)
|
|
148
|
+
errors = validator.validate(fake_tree)
|
|
149
|
+
assert any(
|
|
150
|
+
"master-demo/meta.json" in error and "sources" in error for error in errors
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.parametrize("mutation", ["missing", "extra"])
|
|
155
|
+
def test_contract_keys_are_exact(validator, fake_tree, mutation):
|
|
156
|
+
value = contract("cbeta")
|
|
157
|
+
if mutation == "missing":
|
|
158
|
+
del value["claim_policy"]
|
|
159
|
+
else:
|
|
160
|
+
value["unexpected"] = "value"
|
|
161
|
+
write_meta(fake_tree, "demo", ["cbeta"], value)
|
|
162
|
+
errors = validator.validate(fake_tree)
|
|
163
|
+
assert any(
|
|
164
|
+
"master-demo/meta.json" in error and "citation_contract" in error
|
|
165
|
+
for error in errors
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize("source_type", ["", " ", None, True])
|
|
170
|
+
def test_source_types_must_be_non_empty_strings(
|
|
171
|
+
validator,
|
|
172
|
+
fake_tree,
|
|
173
|
+
source_type,
|
|
174
|
+
):
|
|
175
|
+
write_meta(fake_tree, "demo", [source_type], contract("cbeta"))
|
|
176
|
+
errors = validator.validate(fake_tree)
|
|
177
|
+
assert any(
|
|
178
|
+
"master-demo/meta.json" in error and "sources[].type" in error
|
|
179
|
+
for error in errors
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_cli_exit_codes_and_persona_count(
|
|
184
|
+
validator,
|
|
185
|
+
fake_tree,
|
|
186
|
+
monkeypatch,
|
|
187
|
+
capsys,
|
|
188
|
+
):
|
|
189
|
+
monkeypatch.setattr(validator, "PREBUILT_DIR", fake_tree)
|
|
190
|
+
monkeypatch.setattr(validator, "EXPECTED_PERSONA_SLUGS", ("demo",))
|
|
191
|
+
write_meta(fake_tree, "demo", ["cbeta"], None)
|
|
192
|
+
|
|
193
|
+
assert validator.main() == 1
|
|
194
|
+
assert "master-demo/meta.json" in capsys.readouterr().err
|
|
195
|
+
|
|
196
|
+
write_meta(fake_tree, "demo", ["cbeta"], contract("cbeta"))
|
|
197
|
+
assert validator.main() == 0
|
|
198
|
+
captured = capsys.readouterr()
|
|
199
|
+
assert captured.err == ""
|
|
200
|
+
assert captured.out.strip() == "citation contracts OK (1 personas)"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_repository_mode_fails_when_prebuilt_directory_is_missing(
|
|
204
|
+
validator,
|
|
205
|
+
tmp_path,
|
|
206
|
+
):
|
|
207
|
+
errors = validator.validate_repository(tmp_path / "missing")
|
|
208
|
+
assert any("prebuilt directory" in error for error in errors)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_repository_mode_fails_with_zero_personas(validator, fake_tree):
|
|
212
|
+
write_meta(fake_tree, "debate", [], None, kind="meta-skill")
|
|
213
|
+
errors = validator.validate_repository(fake_tree)
|
|
214
|
+
assert any("persona roster" in error and "missing" in error for error in errors)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_repository_mode_fails_with_fourteen_personas(validator, fake_tree):
|
|
218
|
+
for slug in validator.EXPECTED_PERSONA_SLUGS[:-1]:
|
|
219
|
+
write_meta(fake_tree, slug, ["cbeta"], contract("cbeta"))
|
|
220
|
+
|
|
221
|
+
errors = validator.validate_repository(fake_tree)
|
|
222
|
+
missing = validator.EXPECTED_PERSONA_SLUGS[-1]
|
|
223
|
+
assert any(
|
|
224
|
+
"persona roster" in error and f"master-{missing}" in error
|
|
225
|
+
for error in errors
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_repository_mode_accepts_the_real_fifteen_persona_roster(validator):
|
|
230
|
+
repository = Path(__file__).resolve().parents[2]
|
|
231
|
+
assert validator.validate_repository(repository / "prebuilt") == []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_repository_wording_uses_declared_source_contract():
|
|
235
|
+
repository = Path(__file__).resolve().parents[2]
|
|
236
|
+
runtime_paths = [
|
|
237
|
+
repository / "SKILL.md",
|
|
238
|
+
repository / "prebuilt" / "compare" / "SKILL.md",
|
|
239
|
+
repository / "prompts" / "doctrine_reviewer.md",
|
|
240
|
+
repository / "references" / "ethics-runtime.md",
|
|
241
|
+
repository / "references" / "source-conventions.md",
|
|
242
|
+
repository / "docs" / "PRD.md",
|
|
243
|
+
repository / "docs" / "v1-framework-roadmap.md",
|
|
244
|
+
repository / "ETHICS.md",
|
|
245
|
+
repository / "README.md",
|
|
246
|
+
repository / "README_EN.md",
|
|
247
|
+
repository / "CONTRIBUTING.md",
|
|
248
|
+
]
|
|
249
|
+
forbidden = [
|
|
250
|
+
"NO DOCTRINAL CLAIM WITHOUT CBETA CITATION",
|
|
251
|
+
"每位祖师的回答必须附 CBETA 引用",
|
|
252
|
+
"CBETA 经证覆盖率 ≥ 90%",
|
|
253
|
+
"primary_cbeta_ids 过滤结果",
|
|
254
|
+
"当前版本仅汉传",
|
|
255
|
+
"无 CBETA 经号的教义断言不得写入",
|
|
256
|
+
"每一条教义断言必须附一个**真实**的 CBETA 经号",
|
|
257
|
+
]
|
|
258
|
+
for path in runtime_paths:
|
|
259
|
+
content = path.read_text(encoding="utf-8")
|
|
260
|
+
for phrase in forbidden:
|
|
261
|
+
assert phrase not in content, (
|
|
262
|
+
f"{path}: stale source-family rule: {phrase}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
root_text = runtime_paths[0].read_text(encoding="utf-8")
|
|
266
|
+
compare_text = runtime_paths[1].read_text(encoding="utf-8")
|
|
267
|
+
reviewer_text = runtime_paths[2].read_text(encoding="utf-8")
|
|
268
|
+
conventions_text = runtime_paths[4].read_text(encoding="utf-8")
|
|
269
|
+
|
|
270
|
+
for text in (root_text, compare_text):
|
|
271
|
+
assert "NO DOCTRINAL CLAIM WITHOUT A DECLARED SOURCE CITATION" in text
|
|
272
|
+
assert "citation_contract.allowed_source_types" in text
|
|
273
|
+
assert "meta.json.sources[]" in text
|
|
274
|
+
assert "live_retrieval_allowed" in text
|
|
275
|
+
|
|
276
|
+
assert "primary_cbeta_ids" not in compare_text
|
|
277
|
+
assert "minimum_claim_coverage" in reviewer_text
|
|
278
|
+
assert "同等适用 citation contract" in conventions_text
|
|
279
|
+
contributing_text = runtime_paths[-1].read_text(encoding="utf-8")
|
|
280
|
+
assert "validate-citation-contract.py" in contributing_text
|
|
281
|
+
assert '"citation_contract"' in contributing_text
|
|
282
|
+
assert "meta.json.sources[]" in contributing_text
|
|
283
|
+
for source_family in (
|
|
284
|
+
"CBETA",
|
|
285
|
+
"BDRC / Toh",
|
|
286
|
+
"PTS / SuttaCentral",
|
|
287
|
+
"compiled teachings",
|
|
288
|
+
):
|
|
289
|
+
assert source_family in conventions_text
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_non_cbeta_runtime_instructions_are_source_family_aware():
|
|
293
|
+
repository = Path(__file__).resolve().parents[2]
|
|
294
|
+
slugs = (
|
|
295
|
+
"atisha",
|
|
296
|
+
"tsongkhapa",
|
|
297
|
+
"milarepa",
|
|
298
|
+
"buddhaghosa",
|
|
299
|
+
"mahasi-sayadaw",
|
|
300
|
+
"ajahn-chah",
|
|
301
|
+
)
|
|
302
|
+
for slug in slugs:
|
|
303
|
+
persona = repository / "prebuilt" / f"master-{slug}"
|
|
304
|
+
meta = json.loads((persona / "meta.json").read_text(encoding="utf-8"))
|
|
305
|
+
assert "cbeta" not in meta["citation_contract"]["allowed_source_types"]
|
|
306
|
+
instructions = (persona / "SKILL.md").read_text(encoding="utf-8")
|
|
307
|
+
for stale in ("cbeta_id", "--sources cbeta", "以 CBETA 汉文为主"):
|
|
308
|
+
assert stale not in instructions, f"master-{slug}: stale {stale}"
|
|
309
|
+
for required in (
|
|
310
|
+
"source_type",
|
|
311
|
+
"source_id",
|
|
312
|
+
"meta.json.sources[]",
|
|
313
|
+
"citation_contract.allowed_source_types",
|
|
314
|
+
):
|
|
315
|
+
assert required in instructions, f"master-{slug}: missing {required}"
|
|
316
|
+
|
|
317
|
+
rag = (repository / "prompts" / "rag_instructions.md").read_text(
|
|
318
|
+
encoding="utf-8"
|
|
319
|
+
)
|
|
320
|
+
assert "--sources cbeta" not in rag
|
|
321
|
+
for required in (
|
|
322
|
+
"source_type",
|
|
323
|
+
"source_id",
|
|
324
|
+
"meta.json.sources[]",
|
|
325
|
+
"citation_contract.allowed_source_types",
|
|
326
|
+
):
|
|
327
|
+
assert required in rag
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_live_retrieval_never_uses_sources_outside_the_declared_contract():
|
|
331
|
+
repository = Path(__file__).resolve().parents[2]
|
|
332
|
+
persona_skills = []
|
|
333
|
+
for path in sorted((repository / "prebuilt").glob("master-*/SKILL.md")):
|
|
334
|
+
meta_path = path.parent / "meta.json"
|
|
335
|
+
if not meta_path.is_file():
|
|
336
|
+
continue
|
|
337
|
+
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
338
|
+
if isinstance(metadata.get("sources"), list) and metadata["sources"]:
|
|
339
|
+
persona_skills.append(path)
|
|
340
|
+
assert len(persona_skills) == 15
|
|
341
|
+
for path in persona_skills:
|
|
342
|
+
content = path.read_text(encoding="utf-8")
|
|
343
|
+
for stale in (
|
|
344
|
+
"声明经典之外",
|
|
345
|
+
"所列经典之外",
|
|
346
|
+
"三部经之外",
|
|
347
|
+
"上述三部经之外",
|
|
348
|
+
):
|
|
349
|
+
assert stale not in content, f"{path}: contradictory live trigger {stale}"
|
|
350
|
+
assert "先人工扩充 `sources[]` / citation contract" in content
|
|
351
|
+
assert "已声明来源" in content
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_create_master_docs_match_the_real_generator_cli():
|
|
355
|
+
repository = Path(__file__).resolve().parents[2]
|
|
356
|
+
root = (repository / "SKILL.md").read_text(encoding="utf-8")
|
|
357
|
+
workflow = (repository / "references" / "workflow-details.md").read_text(
|
|
358
|
+
encoding="utf-8"
|
|
359
|
+
)
|
|
360
|
+
conventions = (repository / "references" / "source-conventions.md").read_text(
|
|
361
|
+
encoding="utf-8"
|
|
362
|
+
)
|
|
363
|
+
combined = root + workflow + conventions
|
|
364
|
+
assert "sutra_collector.py --name" in combined
|
|
365
|
+
assert "--output collected_data.json" in combined
|
|
366
|
+
assert "verify_sources.py --check-links collected_data.json" in combined
|
|
367
|
+
assert "master_builder.py --spec generated-master.json" in combined
|
|
368
|
+
assert "verify_sources.py --final-check" in combined
|
|
369
|
+
assert "masters/master-{slug}/" in root
|
|
370
|
+
assert "masters/master-{slug}/" in workflow
|
|
371
|
+
assert "masters/master-{slug}/" in conventions
|
|
372
|
+
assert "masters/{slug}/" not in combined
|
|
373
|
+
assert "生成器内存" in root
|
|
374
|
+
assert "master_builder.py --name" not in combined
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_create_master_prompts_and_contribution_paths_are_source_neutral():
|
|
378
|
+
repository = Path(__file__).resolve().parents[2]
|
|
379
|
+
source_prompts = [
|
|
380
|
+
repository / "prompts" / "sutra_analyzer.md",
|
|
381
|
+
repository / "prompts" / "teaching_builder.md",
|
|
382
|
+
repository / "prompts" / "voice_builder.md",
|
|
383
|
+
]
|
|
384
|
+
for path in source_prompts:
|
|
385
|
+
content = path.read_text(encoding="utf-8")
|
|
386
|
+
for stale in (
|
|
387
|
+
"`cbeta_id`",
|
|
388
|
+
"`fojin_url`",
|
|
389
|
+
"所有经文引用必须附 FoJin 链接",
|
|
390
|
+
"https://fojin.app/texts/{text_id}",
|
|
391
|
+
):
|
|
392
|
+
assert stale not in content, f"{path}: stale {stale}"
|
|
393
|
+
assert "source_id" in content, f"{path}: missing source_id"
|
|
394
|
+
|
|
395
|
+
intake = (repository / "prompts" / "intake.md").read_text(encoding="utf-8")
|
|
396
|
+
for tradition in ("印度", "汉传", "藏传", "南传"):
|
|
397
|
+
assert tradition in intake
|
|
398
|
+
assert "当前版本仅汉传" not in intake
|
|
399
|
+
|
|
400
|
+
pull_request = (
|
|
401
|
+
repository / ".github" / "PULL_REQUEST_TEMPLATE.md"
|
|
402
|
+
).read_text(encoding="utf-8")
|
|
403
|
+
issue = (
|
|
404
|
+
repository / ".github" / "ISSUE_TEMPLATE" / "new_master.yml"
|
|
405
|
+
).read_text(encoding="utf-8")
|
|
406
|
+
for content in (pull_request, issue):
|
|
407
|
+
assert "声明来源" in content
|
|
408
|
+
assert "validate-citation-contract.py" in pull_request
|