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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import importlib.util
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
MODULE_PATH = Path(__file__).resolve().parents[1] / "validate-fidelity.py"
|
|
6
|
+
SPEC = importlib.util.spec_from_file_location("validate_fidelity", MODULE_PATH)
|
|
7
|
+
validate_fidelity = importlib.util.module_from_spec(SPEC)
|
|
8
|
+
SPEC.loader.exec_module(validate_fidelity)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _write_fixture(tmp_path: Path, master_name: str, cases: list[dict]) -> Path:
|
|
12
|
+
master_dir = tmp_path / master_name
|
|
13
|
+
tests_dir = master_dir / "tests"
|
|
14
|
+
tests_dir.mkdir(parents=True)
|
|
15
|
+
payload = "\n".join(json.dumps(case, ensure_ascii=False) for case in cases) + "\n"
|
|
16
|
+
(tests_dir / "fidelity.jsonl").write_text(payload, encoding="utf-8")
|
|
17
|
+
return master_dir
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_compare_requires_framework_output_sections(tmp_path):
|
|
21
|
+
master_dir = _write_fixture(
|
|
22
|
+
tmp_path,
|
|
23
|
+
"compare",
|
|
24
|
+
[
|
|
25
|
+
{
|
|
26
|
+
"q": "禅和净怎么比较?",
|
|
27
|
+
"must_select_masters": ["huineng", "yinguang"],
|
|
28
|
+
"must_have_sections": ["分歧雷达"],
|
|
29
|
+
}
|
|
30
|
+
for _ in range(5)
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
errors = validate_fidelity.validate_master(master_dir)
|
|
35
|
+
|
|
36
|
+
assert any("共同点" in error for error in errors)
|
|
37
|
+
assert any("引用来源" in error for error in errors)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_compare_accepts_required_framework_output_sections(tmp_path):
|
|
41
|
+
case = {
|
|
42
|
+
"q": "禅和净怎么比较?",
|
|
43
|
+
"must_select_masters": ["huineng", "yinguang"],
|
|
44
|
+
"must_have_sections": sorted(validate_fidelity.COMPARE_REQUIRED_SECTIONS),
|
|
45
|
+
}
|
|
46
|
+
cases = [case.copy() for _ in range(5)]
|
|
47
|
+
cases.append(
|
|
48
|
+
{
|
|
49
|
+
"q": "哪个更好?",
|
|
50
|
+
"test_type": "boundary",
|
|
51
|
+
"boundary": "sectarian_judgment",
|
|
52
|
+
"must_not_contain": ["更好"],
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
master_dir = _write_fixture(tmp_path, "compare", cases)
|
|
56
|
+
|
|
57
|
+
errors = validate_fidelity.validate_master(master_dir)
|
|
58
|
+
|
|
59
|
+
assert errors == []
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Structural and behavior checks for the repository validation workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
14
|
+
WORKFLOW_PATH = ROOT / ".github" / "workflows" / "validate-and-test.yml"
|
|
15
|
+
WORKFLOW_TEXT = WORKFLOW_PATH.read_text(encoding="utf-8")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_workflow_text(text: str) -> dict:
|
|
19
|
+
workflow = yaml.safe_load(text)
|
|
20
|
+
assert isinstance(workflow, dict)
|
|
21
|
+
assert isinstance(workflow.get("jobs"), dict)
|
|
22
|
+
return workflow
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _job(workflow: dict, job_name: str) -> dict:
|
|
26
|
+
job = workflow["jobs"].get(job_name)
|
|
27
|
+
assert isinstance(job, dict), f"missing workflow job: {job_name}"
|
|
28
|
+
assert isinstance(job.get("steps"), list), f"job has no steps: {job_name}"
|
|
29
|
+
return job
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _step(workflow: dict, job_name: str, step_name: str) -> dict:
|
|
33
|
+
matches = [
|
|
34
|
+
step
|
|
35
|
+
for step in _job(workflow, job_name)["steps"]
|
|
36
|
+
if step.get("name") == step_name
|
|
37
|
+
]
|
|
38
|
+
assert len(matches) == 1, f"expected one {job_name}/{step_name} step, got {len(matches)}"
|
|
39
|
+
return matches[0]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _assert_hard(step: dict) -> None:
|
|
43
|
+
assert step.get("continue-on-error") not in (True, "true")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
WORKFLOW = _load_workflow_text(WORKFLOW_TEXT)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_free_text_tokens_cannot_substitute_for_workflow_structure():
|
|
50
|
+
fake = _load_workflow_text(
|
|
51
|
+
"""\
|
|
52
|
+
name: fake
|
|
53
|
+
jobs:
|
|
54
|
+
validate:
|
|
55
|
+
steps:
|
|
56
|
+
- name: comments only
|
|
57
|
+
run: echo 'python -m pytest tests/ scripts/tests/ -v'
|
|
58
|
+
"""
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
with pytest.raises(AssertionError, match="Run Python tests"):
|
|
62
|
+
_step(fake, "validate", "Run Python tests")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_workflow_has_no_softened_steps():
|
|
66
|
+
for job_name, job in WORKFLOW["jobs"].items():
|
|
67
|
+
for step in job.get("steps", []):
|
|
68
|
+
assert step.get("continue-on-error") not in (True, "true"), (
|
|
69
|
+
f"softened workflow step in {job_name}: {step.get('name', step.get('uses'))}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
("step_name", "command"),
|
|
75
|
+
[
|
|
76
|
+
("Run Python tests", "python -m pytest tests/ scripts/tests/ -v"),
|
|
77
|
+
("Validate citation contracts", "python scripts/validate-citation-contract.py"),
|
|
78
|
+
(
|
|
79
|
+
"Validate lore_triggers content (v0.8 — hard gate)",
|
|
80
|
+
"python scripts/validate-lore-triggers-content.py --strict",
|
|
81
|
+
),
|
|
82
|
+
],
|
|
83
|
+
)
|
|
84
|
+
def test_validate_job_contains_hard_gate_commands(step_name: str, command: str):
|
|
85
|
+
step = _step(WORKFLOW, "validate", step_name)
|
|
86
|
+
assert step.get("run") == command
|
|
87
|
+
_assert_hard(step)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize(
|
|
91
|
+
("step_name", "command"),
|
|
92
|
+
[
|
|
93
|
+
("Check desktop formatting", "cargo fmt --manifest-path desktop/Cargo.toml -- --check"),
|
|
94
|
+
(
|
|
95
|
+
"Lint desktop app",
|
|
96
|
+
"cargo clippy --locked --manifest-path desktop/Cargo.toml "
|
|
97
|
+
"--all-targets -- -D warnings",
|
|
98
|
+
),
|
|
99
|
+
("Test desktop app", "cargo test --locked --manifest-path desktop/Cargo.toml"),
|
|
100
|
+
("Build desktop app", "cargo build --locked --manifest-path desktop/Cargo.toml"),
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
def test_desktop_job_contains_hard_gate_commands(step_name: str, command: str):
|
|
104
|
+
step = _step(WORKFLOW, "desktop-rust", step_name)
|
|
105
|
+
assert step.get("run") == command
|
|
106
|
+
_assert_hard(step)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_desktop_quality_gates_run_before_tests_and_build():
|
|
110
|
+
step_names = [step.get("name") for step in _job(WORKFLOW, "desktop-rust")["steps"]]
|
|
111
|
+
assert step_names.index("Check desktop formatting") < step_names.index("Lint desktop app")
|
|
112
|
+
assert step_names.index("Lint desktop app") < step_names.index("Test desktop app")
|
|
113
|
+
assert step_names.index("Test desktop app") < step_names.index("Build desktop app")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_windows_cli_job_installs_the_generator_python_runtime():
|
|
117
|
+
job = _job(WORKFLOW, "cli-windows")
|
|
118
|
+
uses = [step.get("uses", "") for step in job["steps"]]
|
|
119
|
+
assert any(use.startswith("actions/setup-python@") for use in uses)
|
|
120
|
+
install = _step(WORKFLOW, "cli-windows", "Install generator dependencies")
|
|
121
|
+
assert install.get("run") == "python -m pip install -r requirements.txt"
|
|
122
|
+
_assert_hard(install)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_python39_job_compiles_and_runs_the_four_generator_cli_steps():
|
|
126
|
+
job = _job(WORKFLOW, "python-compat")
|
|
127
|
+
setup = next(
|
|
128
|
+
step
|
|
129
|
+
for step in job["steps"]
|
|
130
|
+
if str(step.get("uses", "")).startswith("actions/setup-python@")
|
|
131
|
+
)
|
|
132
|
+
assert setup.get("with", {}).get("python-version") == "3.9"
|
|
133
|
+
smoke = _step(WORKFLOW, "python-compat", "Run Python 3.9 generator smoke")
|
|
134
|
+
command = smoke["run"]
|
|
135
|
+
assert "python -m compileall -q tools scripts" in command
|
|
136
|
+
assert "sutra_collector.py" in command
|
|
137
|
+
assert "verify_sources.py --check-links" in command
|
|
138
|
+
assert "master_builder.py" in command
|
|
139
|
+
assert "verify_sources.py --final-check" in command
|
|
140
|
+
_assert_hard(smoke)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_push_paths_include_distribution_and_generator_runtime():
|
|
144
|
+
triggers = WORKFLOW.get("on", WORKFLOW.get(True))
|
|
145
|
+
assert isinstance(triggers, dict)
|
|
146
|
+
paths = set(triggers["push"]["paths"])
|
|
147
|
+
assert {
|
|
148
|
+
"skill-catalog.json",
|
|
149
|
+
"SKILL.md",
|
|
150
|
+
"references/**",
|
|
151
|
+
"ETHICS.md",
|
|
152
|
+
"requirements.txt",
|
|
153
|
+
"README.md",
|
|
154
|
+
"README_EN.md",
|
|
155
|
+
"CONTRIBUTING.md",
|
|
156
|
+
"CHANGELOG.md",
|
|
157
|
+
"docs/PRD.md",
|
|
158
|
+
"docs/v1-framework-roadmap.md",
|
|
159
|
+
"masters/**",
|
|
160
|
+
".claude-plugin/**",
|
|
161
|
+
".cursor-plugin/**",
|
|
162
|
+
"gemini-extension.json",
|
|
163
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
164
|
+
".github/ISSUE_TEMPLATE/**",
|
|
165
|
+
} <= paths
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_pick_step_uses_checked_selector_without_fixed_roster():
|
|
169
|
+
checkout = next(
|
|
170
|
+
step
|
|
171
|
+
for step in _job(WORKFLOW, "fidelity-smoke")["steps"]
|
|
172
|
+
if str(step.get("uses", "")).startswith("actions/checkout@")
|
|
173
|
+
)
|
|
174
|
+
assert checkout.get("with", {}).get("fetch-depth") == 0
|
|
175
|
+
step = _step(WORKFLOW, "fidelity-smoke", "Pick smoke target")
|
|
176
|
+
script = step["run"]
|
|
177
|
+
assert "if ! CHANGED=$(python scripts/select-fidelity-smoke.py" in script
|
|
178
|
+
assert "if ! DIFF_OUTPUT=$(git diff --name-only" in script
|
|
179
|
+
assert "CHANGED_CANDIDATES" in script
|
|
180
|
+
assert "--prebuilt prebuilt" in script
|
|
181
|
+
assert '--day-of-year "$(date +%j)"' in script
|
|
182
|
+
assert "MASTERS=(" not in script
|
|
183
|
+
_assert_hard(step)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_smoke_selector_producer_failure_is_not_masked(tmp_path: Path):
|
|
187
|
+
pick_step = _step(WORKFLOW, "fidelity-smoke", "Pick smoke target")
|
|
188
|
+
script = pick_step["run"].replace("${{ github.base_ref || 'main' }}", "main")
|
|
189
|
+
|
|
190
|
+
bin_dir = tmp_path / "bin"
|
|
191
|
+
bin_dir.mkdir()
|
|
192
|
+
fake_python = bin_dir / "python"
|
|
193
|
+
fake_python.write_text(
|
|
194
|
+
"#!/bin/sh\nprintf 'master-partial\\n'\nexit 7\n",
|
|
195
|
+
encoding="utf-8",
|
|
196
|
+
)
|
|
197
|
+
fake_python.chmod(0o755)
|
|
198
|
+
output_path = tmp_path / "github-output"
|
|
199
|
+
env = os.environ.copy()
|
|
200
|
+
env["PATH"] = f"{bin_dir}:{env['PATH']}"
|
|
201
|
+
env["GITHUB_OUTPUT"] = str(output_path)
|
|
202
|
+
|
|
203
|
+
result = subprocess.run(
|
|
204
|
+
["bash", "-e", "-o", "pipefail", "-c", script],
|
|
205
|
+
cwd=ROOT,
|
|
206
|
+
env=env,
|
|
207
|
+
text=True,
|
|
208
|
+
capture_output=True,
|
|
209
|
+
check=False,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
assert result.returncode != 0
|
|
213
|
+
assert not output_path.exists()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_smoke_git_diff_failure_is_not_masked(tmp_path: Path):
|
|
217
|
+
pick_step = _step(WORKFLOW, "fidelity-smoke", "Pick smoke target")
|
|
218
|
+
script = pick_step["run"].replace("${{ github.base_ref || 'main' }}", "main")
|
|
219
|
+
|
|
220
|
+
bin_dir = tmp_path / "bin"
|
|
221
|
+
bin_dir.mkdir()
|
|
222
|
+
fake_git = bin_dir / "git"
|
|
223
|
+
fake_git.write_text("#!/bin/sh\nexit 7\n", encoding="utf-8")
|
|
224
|
+
fake_git.chmod(0o755)
|
|
225
|
+
fake_python = bin_dir / "python"
|
|
226
|
+
fake_python.write_text(
|
|
227
|
+
"#!/bin/sh\nprintf 'master-alpha\\n'\n",
|
|
228
|
+
encoding="utf-8",
|
|
229
|
+
)
|
|
230
|
+
fake_python.chmod(0o755)
|
|
231
|
+
output_path = tmp_path / "github-output"
|
|
232
|
+
env = os.environ.copy()
|
|
233
|
+
env["PATH"] = f"{bin_dir}:{env['PATH']}"
|
|
234
|
+
env["GITHUB_OUTPUT"] = str(output_path)
|
|
235
|
+
|
|
236
|
+
result = subprocess.run(
|
|
237
|
+
["bash", "-e", "-o", "pipefail", "-c", script],
|
|
238
|
+
cwd=ROOT,
|
|
239
|
+
env=env,
|
|
240
|
+
text=True,
|
|
241
|
+
capture_output=True,
|
|
242
|
+
check=False,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
assert result.returncode != 0
|
|
246
|
+
assert "git diff failed" in result.stdout
|
|
247
|
+
assert not output_path.exists()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.mark.parametrize(
|
|
251
|
+
("job_name", "step_name"),
|
|
252
|
+
[
|
|
253
|
+
("fidelity-smoke", "Run fidelity smoke"),
|
|
254
|
+
("fidelity-full", "Run fidelity tests"),
|
|
255
|
+
],
|
|
256
|
+
)
|
|
257
|
+
def test_each_fidelity_no_key_branch_records_step_summary(job_name: str, step_name: str):
|
|
258
|
+
job = _job(WORKFLOW, job_name)
|
|
259
|
+
assert job.get("needs") == "validate"
|
|
260
|
+
step = _step(WORKFLOW, job_name, step_name)
|
|
261
|
+
script = step["run"]
|
|
262
|
+
assert 'if [ -z "${ANTHROPIC_API_KEY:-}" ]; then' in script
|
|
263
|
+
assert script.count('echo "### Fidelity grading skipped"') == 1
|
|
264
|
+
assert '} >> "$GITHUB_STEP_SUMMARY"' in script
|
|
265
|
+
_assert_hard(step)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate source-neutral citation contracts in persona metadata."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
|
|
11
|
+
|
|
12
|
+
EXPECTED_PERSONA_SLUGS = (
|
|
13
|
+
"ajahn-chah",
|
|
14
|
+
"atisha",
|
|
15
|
+
"buddhaghosa",
|
|
16
|
+
"fazang",
|
|
17
|
+
"huineng",
|
|
18
|
+
"kumarajiva",
|
|
19
|
+
"mahasi-sayadaw",
|
|
20
|
+
"milarepa",
|
|
21
|
+
"nagarjuna",
|
|
22
|
+
"ouyi",
|
|
23
|
+
"tsongkhapa",
|
|
24
|
+
"xuanzang",
|
|
25
|
+
"xuyun",
|
|
26
|
+
"yinguang",
|
|
27
|
+
"zhiyi",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
EXPECTED_REQUIRED_FOR = [
|
|
31
|
+
"doctrinal_claim",
|
|
32
|
+
"practice_guidance",
|
|
33
|
+
"text_interpretation",
|
|
34
|
+
]
|
|
35
|
+
EXPECTED_KEYS = {
|
|
36
|
+
"version",
|
|
37
|
+
"claim_policy",
|
|
38
|
+
"required_for",
|
|
39
|
+
"allowed_source_types",
|
|
40
|
+
"minimum_claim_coverage",
|
|
41
|
+
"live_retrieval_allowed",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _display_path(meta_path: Path) -> str:
|
|
46
|
+
return f"{meta_path.parent.name}/meta.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _persona_count(prebuilt_dir: Path) -> int:
|
|
50
|
+
count = 0
|
|
51
|
+
for meta_path in prebuilt_dir.glob("master-*/meta.json"):
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, json.JSONDecodeError):
|
|
55
|
+
continue
|
|
56
|
+
if data.get("kind") != "meta-skill":
|
|
57
|
+
count += 1
|
|
58
|
+
return count
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate(prebuilt_dir: Path) -> list[str]:
|
|
62
|
+
"""Return deterministic citation-contract errors for persona metadata."""
|
|
63
|
+
errors: list[str] = []
|
|
64
|
+
|
|
65
|
+
for meta_path in sorted(prebuilt_dir.glob("master-*/meta.json")):
|
|
66
|
+
path = _display_path(meta_path)
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
69
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
70
|
+
errors.append(f"{path}: invalid metadata: {exc}")
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if data.get("kind") == "meta-skill":
|
|
74
|
+
if "citation_contract" in data:
|
|
75
|
+
errors.append(
|
|
76
|
+
f"{path}: meta-skill must not declare citation_contract"
|
|
77
|
+
)
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
sources = data.get("sources")
|
|
81
|
+
if not isinstance(sources, list) or not sources:
|
|
82
|
+
errors.append(f"{path}: persona sources must be a non-empty list")
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
source_types: list[str] = []
|
|
86
|
+
for index, source in enumerate(sources):
|
|
87
|
+
source_type = source.get("type") if isinstance(source, dict) else None
|
|
88
|
+
if not isinstance(source_type, str) or not source_type.strip():
|
|
89
|
+
errors.append(
|
|
90
|
+
f"{path}: sources[].type at index {index} must be a non-empty string"
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
source_types.append(source_type)
|
|
94
|
+
|
|
95
|
+
contract = data.get("citation_contract")
|
|
96
|
+
if not isinstance(contract, dict):
|
|
97
|
+
errors.append(f"{path}: citation_contract must be an object")
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
contract_keys = set(contract)
|
|
101
|
+
if contract_keys != EXPECTED_KEYS:
|
|
102
|
+
missing = sorted(EXPECTED_KEYS - contract_keys)
|
|
103
|
+
extra = sorted(contract_keys - EXPECTED_KEYS)
|
|
104
|
+
errors.append(
|
|
105
|
+
f"{path}: citation_contract keys must be exact; "
|
|
106
|
+
f"missing={missing}, extra={extra}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
version = contract.get("version")
|
|
110
|
+
if type(version) is not int or version != 1:
|
|
111
|
+
errors.append(f"{path}: version must be integer 1")
|
|
112
|
+
|
|
113
|
+
if contract.get("claim_policy") != "declared_sources_only":
|
|
114
|
+
errors.append(
|
|
115
|
+
f"{path}: claim_policy must be 'declared_sources_only'"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if contract.get("required_for") != EXPECTED_REQUIRED_FOR:
|
|
119
|
+
errors.append(
|
|
120
|
+
f"{path}: required_for must equal {EXPECTED_REQUIRED_FOR}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
allowed_source_types = contract.get("allowed_source_types")
|
|
124
|
+
expected_source_types = sorted(set(source_types))
|
|
125
|
+
if (
|
|
126
|
+
not isinstance(allowed_source_types, list)
|
|
127
|
+
or any(
|
|
128
|
+
not isinstance(source_type, str) or not source_type.strip()
|
|
129
|
+
for source_type in allowed_source_types
|
|
130
|
+
)
|
|
131
|
+
or allowed_source_types != expected_source_types
|
|
132
|
+
):
|
|
133
|
+
errors.append(
|
|
134
|
+
f"{path}: allowed_source_types must equal {expected_source_types}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
coverage = contract.get("minimum_claim_coverage")
|
|
138
|
+
if (
|
|
139
|
+
isinstance(coverage, bool)
|
|
140
|
+
or not isinstance(coverage, (int, float))
|
|
141
|
+
or coverage != 0.9
|
|
142
|
+
):
|
|
143
|
+
errors.append(f"{path}: minimum_claim_coverage must be numeric 0.9")
|
|
144
|
+
|
|
145
|
+
if contract.get("live_retrieval_allowed") is not True:
|
|
146
|
+
errors.append(f"{path}: live_retrieval_allowed must be true")
|
|
147
|
+
|
|
148
|
+
return errors
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def validate_repository(prebuilt_dir: Path) -> list[str]:
|
|
152
|
+
"""Validate contracts plus the repository's fixed 15-persona roster."""
|
|
153
|
+
if not prebuilt_dir.is_dir():
|
|
154
|
+
return [f"{prebuilt_dir}: prebuilt directory not found"]
|
|
155
|
+
|
|
156
|
+
errors = validate(prebuilt_dir)
|
|
157
|
+
discovered: set[str] = set()
|
|
158
|
+
for meta_path in sorted(prebuilt_dir.glob("master-*/meta.json")):
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
161
|
+
except (OSError, json.JSONDecodeError):
|
|
162
|
+
continue
|
|
163
|
+
if data.get("kind") == "meta-skill":
|
|
164
|
+
continue
|
|
165
|
+
discovered.add(meta_path.parent.name.removeprefix("master-"))
|
|
166
|
+
|
|
167
|
+
expected = set(EXPECTED_PERSONA_SLUGS)
|
|
168
|
+
missing = sorted(expected - discovered)
|
|
169
|
+
unexpected = sorted(discovered - expected)
|
|
170
|
+
if missing or unexpected or len(discovered) != len(EXPECTED_PERSONA_SLUGS):
|
|
171
|
+
errors.append(
|
|
172
|
+
"persona roster must contain exactly "
|
|
173
|
+
f"{len(EXPECTED_PERSONA_SLUGS)} declared personas; "
|
|
174
|
+
f"missing={[f'master-{slug}' for slug in missing]}, "
|
|
175
|
+
f"unexpected={[f'master-{slug}' for slug in unexpected]}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return errors
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main() -> int:
|
|
182
|
+
errors = validate_repository(PREBUILT_DIR)
|
|
183
|
+
if errors:
|
|
184
|
+
for error in errors:
|
|
185
|
+
print(error, file=sys.stderr)
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
print(f"citation contracts OK ({_persona_count(PREBUILT_DIR)} personas)")
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
sys.exit(main())
|
|
@@ -39,6 +39,17 @@ VALID_PRESSURES = {
|
|
|
39
39
|
"misunderstanding_challenge",
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
COMPARE_REQUIRED_SECTIONS = {
|
|
43
|
+
"共同点",
|
|
44
|
+
"核心分歧",
|
|
45
|
+
"适用根机",
|
|
46
|
+
"分歧雷达",
|
|
47
|
+
"分歧分类",
|
|
48
|
+
"共通点与宗派背景",
|
|
49
|
+
"推荐继续追问",
|
|
50
|
+
"引用来源",
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
|
|
43
54
|
def validate_master(master_dir: Path) -> list[str]:
|
|
44
55
|
"""Validate fidelity.jsonl for a single master. Returns list of errors."""
|
|
@@ -122,6 +133,15 @@ def validate_master(master_dir: Path) -> list[str]:
|
|
|
122
133
|
if field in test and not isinstance(test[field], list):
|
|
123
134
|
errors.append(f"{master_dir.name}:{i}: '{field}' must be a list")
|
|
124
135
|
|
|
136
|
+
if master_dir.name == "compare" and test_type not in {"boundary", "pressure"}:
|
|
137
|
+
sections = set(test.get("must_have_sections", []))
|
|
138
|
+
missing = sorted(COMPARE_REQUIRED_SECTIONS - sections)
|
|
139
|
+
if missing:
|
|
140
|
+
errors.append(
|
|
141
|
+
f"{master_dir.name}:{i}: compare test missing required output "
|
|
142
|
+
f"sections: {', '.join(missing)}"
|
|
143
|
+
)
|
|
144
|
+
|
|
125
145
|
# Check coverage: should have at least one boundary test
|
|
126
146
|
has_boundary = any(
|
|
127
147
|
json.loads(l).get("test_type") == "boundary"
|
|
@@ -33,7 +33,14 @@ _SAFE_MASTER = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
|
33
33
|
|
|
34
34
|
# CBETA id 形态:T48n2008 / T08n0235(藏经卷+n+编号),及 API 返回的 X1218 / X0303
|
|
35
35
|
# (无卷号)。无 `n` 的形态只认 T/X 两个集合,避免误吞 Wikidata 的 Q1234 / P5008。
|
|
36
|
-
|
|
36
|
+
#
|
|
37
|
+
# 边界不能用 \b:Python 的 \w 覆盖 CJK,故「卷一T99n9999」的「一」与「T」之间
|
|
38
|
+
# 没有 \b,整块引文会被 audit_answer 当作无 id 跳过 —— 而格式跑偏正是模型最可能
|
|
39
|
+
# 编造经号的时候。改判「前后不是拉丁字母或数字」:汉字紧邻属真实引文形态,须命中;
|
|
40
|
+
# 拉丁字母紧邻(FakeSutraT99n9999)通常意味着它只是更长 token 的一部分,不算引文。
|
|
41
|
+
_CBETA_ID = re.compile(
|
|
42
|
+
r"(?<![0-9A-Za-z])(?:[A-Z]{1,2}\d+n\d+|[TX]\d{3,})(?![0-9A-Za-z])"
|
|
43
|
+
)
|
|
37
44
|
# 引文块 【…】
|
|
38
45
|
_CITATION_BLOCK = re.compile(r"【([^】]*)】")
|
|
39
46
|
# live 链接 fojin.app/texts/<数字>
|