master-skill 0.10.0 → 0.11.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/ETHICS.md +23 -17
- package/GEMINI.md +1 -1
- package/README.md +57 -295
- package/README_EN.md +59 -276
- package/SKILL.md +5 -5
- package/bin/cli.mjs +545 -78
- package/gemini-extension.json +1 -1
- package/hooks/run-hook.cmd +18 -5
- package/hooks/session-start +4 -1
- 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 +10 -3
- package/prebuilt/{compare → compare-masters}/SKILL.md +5 -5
- 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-curriculum/SKILL.md +1 -1
- package/prebuilt/master-debate/SKILL.md +1 -1
- package/prebuilt/master-fazang/SKILL.md +3 -3
- package/prebuilt/master-fazang/meta.json +8 -0
- package/prebuilt/master-help/SKILL.md +86 -0
- package/prebuilt/master-help/tests/fidelity.jsonl +10 -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 +20 -1
- 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 +25 -2
- 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 +32 -3
- 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 +91 -0
- package/references/traditions.md +72 -0
- package/references/workflow-details.md +361 -0
- package/requirements.txt +6 -0
- package/routing.json +209 -0
- package/scripts/check-gate-liveness.py +222 -0
- package/scripts/select-fidelity-smoke.py +78 -0
- package/scripts/test-fidelity.py +339 -51
- package/scripts/tests/test_check_gate_liveness.py +232 -0
- package/scripts/tests/test_check_response.py +190 -0
- package/scripts/tests/test_fidelity_providers.py +202 -0
- package/scripts/tests/test_select_fidelity_smoke.py +142 -0
- package/scripts/tests/test_validate.py +145 -0
- package/scripts/tests/test_validate_citation_contract.py +408 -0
- package/scripts/tests/test_validate_fidelity.py +2 -2
- package/scripts/tests/test_validate_workflow.py +284 -0
- package/scripts/validate-citation-contract.py +193 -0
- package/scripts/validate-fidelity.py +6 -1
- package/scripts/validate-routing.py +254 -0
- package/scripts/validate.py +63 -36
- package/scripts/verify_citations.py +8 -1
- package/skill-catalog.json +210 -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/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Validate declared persona source manifests, or audit legacy FoJin links.
|
|
4
|
+
|
|
5
|
+
Discovers CBETA IDs from meta.json sources and fojin.app URLs in markdown
|
|
6
|
+
files, then verifies each against FoJin's API and maps to internal text_ids.
|
|
7
|
+
|
|
8
|
+
Key insight: meta.json and URLs use the full CBETA catalog format (e.g.
|
|
9
|
+
T08n0235) while FoJin internally uses a shorter cbeta_id (e.g. T0235).
|
|
10
|
+
This script handles the conversion.
|
|
11
|
+
|
|
12
|
+
Offline modes validate family identifiers, declared membership, and citation
|
|
13
|
+
contracts. They do not parse free-text citations or check HTTP reachability.
|
|
14
|
+
The legacy no-argument / --fix modes only audit repository CBETA/FoJin URLs.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# Allow importing fojin_bridge from tools/
|
|
28
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
|
29
|
+
|
|
30
|
+
from fojin_bridge import create_bridge
|
|
31
|
+
from skill_writer import derive_citation_contract
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
35
|
+
PREBUILT_DIR = os.path.join(PROJECT_ROOT, "prebuilt")
|
|
36
|
+
|
|
37
|
+
# Matches fojin.app/texts/<ID> in URLs — ID can be CBETA-style or numeric
|
|
38
|
+
FOJIN_URL_RE = re.compile(r"(https?://fojin\.app/texts/)([A-Za-z0-9n]+)")
|
|
39
|
+
|
|
40
|
+
# Full CBETA catalog ID pattern: T08n0235, X62n1182, J36n0348
|
|
41
|
+
FULL_CBETA_RE = re.compile(r"^([A-Z])(\d+)n(\d+[a-z]?)$")
|
|
42
|
+
|
|
43
|
+
SOURCE_ID_PATTERNS = {
|
|
44
|
+
"cbeta": FULL_CBETA_RE,
|
|
45
|
+
"tibetan_canon": re.compile(r"^(?:Toh[: ]\d+[A-Za-z-]*|BDRC:[A-Za-z0-9][A-Za-z0-9-]*)$"),
|
|
46
|
+
"kadam_corpus": re.compile(r"^BDRC:[A-Za-z0-9][A-Za-z0-9-]*$"),
|
|
47
|
+
"tibetan_treatise": re.compile(r"^[A-Za-z][A-Za-z0-9'-]*(?:-[A-Za-z0-9'-]+)*$"),
|
|
48
|
+
"pali_canon": re.compile(r"^(?:SuttaCentral|SC[: ][A-Za-z0-9. -]+|(?:DN|MN|SN|AN|KN) ?\d+(?:\.\d+)?)$"),
|
|
49
|
+
"pali_commentary": re.compile(r"^PTS:[A-Za-z0-9][A-Za-z0-9-]*$"),
|
|
50
|
+
"pali_treatise": re.compile(r"^PTS:[A-Za-z0-9][A-Za-z0-9-]*$"),
|
|
51
|
+
"compiled_teaching": re.compile(r"^[^:\s]+:[^:\s].+$"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_source_document(document: dict) -> list[str]:
|
|
56
|
+
"""Validate declared source families, identifiers, contract, and citations."""
|
|
57
|
+
errors: list[str] = []
|
|
58
|
+
sources = document.get("sources") if isinstance(document, dict) else None
|
|
59
|
+
if not isinstance(sources, list) or not sources:
|
|
60
|
+
return ["sources must be a non-empty list"]
|
|
61
|
+
|
|
62
|
+
declared: set[tuple[str, str]] = set()
|
|
63
|
+
for index, source in enumerate(sources):
|
|
64
|
+
if not isinstance(source, dict):
|
|
65
|
+
errors.append(f"sources[{index}] must be an object")
|
|
66
|
+
continue
|
|
67
|
+
source_type = source.get("type")
|
|
68
|
+
source_id = source.get("id")
|
|
69
|
+
if source_type not in SOURCE_ID_PATTERNS:
|
|
70
|
+
errors.append(f"sources[{index}].type is unsupported: {source_type!r}")
|
|
71
|
+
continue
|
|
72
|
+
if not isinstance(source_id, str) or not SOURCE_ID_PATTERNS[source_type].fullmatch(source_id):
|
|
73
|
+
errors.append(
|
|
74
|
+
f"sources[{index}] {source_type} identifier is invalid: {source_id!r}"
|
|
75
|
+
)
|
|
76
|
+
continue
|
|
77
|
+
member = (source_type, source_id)
|
|
78
|
+
if member in declared:
|
|
79
|
+
errors.append(
|
|
80
|
+
f"sources[{index}] duplicates declared source {source_type}:{source_id}"
|
|
81
|
+
)
|
|
82
|
+
declared.add(member)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
expected_contract = derive_citation_contract(sources)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
errors.append(str(exc))
|
|
88
|
+
else:
|
|
89
|
+
if document.get("citation_contract") != expected_contract:
|
|
90
|
+
errors.append(
|
|
91
|
+
"citation_contract must equal the contract derived from sources[].type"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
citations = document.get("citations", [])
|
|
95
|
+
if not isinstance(citations, list):
|
|
96
|
+
errors.append("citations must be a list when present")
|
|
97
|
+
else:
|
|
98
|
+
for index, citation in enumerate(citations):
|
|
99
|
+
if not isinstance(citation, dict):
|
|
100
|
+
errors.append(f"citations[{index}] must be an object")
|
|
101
|
+
continue
|
|
102
|
+
member = (citation.get("type"), citation.get("id"))
|
|
103
|
+
if member not in declared:
|
|
104
|
+
errors.append(
|
|
105
|
+
f"citations[{index}] {member[0]}:{member[1]} is not declared in sources[]"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return errors
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load_declared_source_target(target: str, *, final: bool) -> tuple[dict | None, list[str]]:
|
|
112
|
+
path = Path(target)
|
|
113
|
+
errors: list[str] = []
|
|
114
|
+
if final:
|
|
115
|
+
if not path.is_dir():
|
|
116
|
+
return None, [f"final-check target is not a persona directory: {path}"]
|
|
117
|
+
missing = [
|
|
118
|
+
name
|
|
119
|
+
for name in ("SKILL.md", "teaching.md", "voice.md", "meta.json")
|
|
120
|
+
if not (path / name).is_file()
|
|
121
|
+
]
|
|
122
|
+
if missing:
|
|
123
|
+
return None, [f"final-check target is missing required files: {missing}"]
|
|
124
|
+
if not re.fullmatch(r"master-[a-z0-9][a-z0-9-]*", path.name):
|
|
125
|
+
errors.append(
|
|
126
|
+
"final-check persona directory must use master-<slug>: "
|
|
127
|
+
f"{path.name}"
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
skill_text = (path / "SKILL.md").read_text(encoding="utf-8")
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
errors.append(f"cannot read final SKILL.md: {exc}")
|
|
133
|
+
else:
|
|
134
|
+
name_match = re.search(r"(?m)^name:\s*([^\s]+)\s*$", skill_text)
|
|
135
|
+
skill_name = name_match.group(1) if name_match else None
|
|
136
|
+
if skill_name != path.name:
|
|
137
|
+
errors.append(
|
|
138
|
+
f"SKILL.md name {skill_name!r} must equal directory "
|
|
139
|
+
f"{path.name!r}"
|
|
140
|
+
)
|
|
141
|
+
path = path / "meta.json"
|
|
142
|
+
elif not path.is_file():
|
|
143
|
+
return None, [f"check-links input file not found: {path}"]
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(path.read_text(encoding="utf-8")), errors
|
|
147
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
148
|
+
errors.append(f"cannot read source manifest {path}: {exc}")
|
|
149
|
+
return None, errors
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _run_declared_source_check(target: str, *, final: bool) -> int:
|
|
153
|
+
document, errors = _load_declared_source_target(target, final=final)
|
|
154
|
+
if document is not None:
|
|
155
|
+
errors.extend(validate_source_document(document))
|
|
156
|
+
if errors:
|
|
157
|
+
for error in errors:
|
|
158
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
159
|
+
return 1
|
|
160
|
+
label = "final source check" if final else "declared sources"
|
|
161
|
+
print(f"{label} OK ({len(document['sources'])} sources)")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def full_to_short_cbeta(full_id: str) -> str | None:
|
|
166
|
+
"""Convert full CBETA ID (T08n0235) to FoJin short format (T0235).
|
|
167
|
+
|
|
168
|
+
FoJin stores cbeta_id as the collection prefix + text number,
|
|
169
|
+
dropping the volume number. E.g.:
|
|
170
|
+
T08n0235 -> T0235
|
|
171
|
+
X62n1182 -> X1182
|
|
172
|
+
J36n0348 -> J0348
|
|
173
|
+
T34n1718 -> T1718
|
|
174
|
+
"""
|
|
175
|
+
m = FULL_CBETA_RE.match(full_id)
|
|
176
|
+
if not m:
|
|
177
|
+
return None
|
|
178
|
+
prefix = m.group(1) # T, X, J, etc.
|
|
179
|
+
text_num = m.group(3) # 0235, 1182, etc.
|
|
180
|
+
return f"{prefix}{text_num}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def collect_cbeta_ids() -> dict[str, list[str]]:
|
|
184
|
+
"""Scan all meta.json and return {full_cbeta_id: [teacher_slugs]}."""
|
|
185
|
+
cbeta_map: dict[str, list[str]] = {}
|
|
186
|
+
for teacher in sorted(os.listdir(PREBUILT_DIR)):
|
|
187
|
+
meta_path = os.path.join(PREBUILT_DIR, teacher, "meta.json")
|
|
188
|
+
if not os.path.isfile(meta_path):
|
|
189
|
+
continue
|
|
190
|
+
with open(meta_path, encoding="utf-8") as f:
|
|
191
|
+
meta = json.load(f)
|
|
192
|
+
for src in meta.get("sources", []):
|
|
193
|
+
if src.get("type") == "cbeta" and src.get("id"):
|
|
194
|
+
cbeta_id = src["id"]
|
|
195
|
+
cbeta_map.setdefault(cbeta_id, []).append(teacher)
|
|
196
|
+
return cbeta_map
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def collect_all_fojin_urls() -> dict[str, list[tuple[str, int]]]:
|
|
200
|
+
"""Scan all md/py files and return {id_in_url: [(filepath, line_num)]}."""
|
|
201
|
+
url_map: dict[str, list[tuple[str, int]]] = {}
|
|
202
|
+
|
|
203
|
+
scan_dirs = [PREBUILT_DIR, os.path.join(PROJECT_ROOT, "prompts")]
|
|
204
|
+
extensions = {".md", ".py"}
|
|
205
|
+
|
|
206
|
+
for scan_dir in scan_dirs:
|
|
207
|
+
if not os.path.isdir(scan_dir):
|
|
208
|
+
continue
|
|
209
|
+
for root, _dirs, files in os.walk(scan_dir):
|
|
210
|
+
for fname in files:
|
|
211
|
+
if os.path.splitext(fname)[1] not in extensions:
|
|
212
|
+
continue
|
|
213
|
+
fpath = os.path.join(root, fname)
|
|
214
|
+
with open(fpath, encoding="utf-8") as f:
|
|
215
|
+
for line_num, line in enumerate(f, 1):
|
|
216
|
+
for m in FOJIN_URL_RE.finditer(line):
|
|
217
|
+
text_id_in_url = m.group(2)
|
|
218
|
+
url_map.setdefault(text_id_in_url, []).append(
|
|
219
|
+
(fpath, line_num)
|
|
220
|
+
)
|
|
221
|
+
return url_map
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def verify_via_search(bridge, title: str, short_cbeta_id: str) -> dict | None:
|
|
225
|
+
"""Search FoJin for a text by title, return the matching result with cbeta_id match."""
|
|
226
|
+
try:
|
|
227
|
+
resp = bridge.search_texts(title, sources="cbeta", page=1, size=5)
|
|
228
|
+
for r in resp.get("results", []):
|
|
229
|
+
if r.get("cbeta_id") == short_cbeta_id:
|
|
230
|
+
return r
|
|
231
|
+
# Also try without source filter
|
|
232
|
+
resp = bridge.search_texts(title, page=1, size=5)
|
|
233
|
+
for r in resp.get("results", []):
|
|
234
|
+
if r.get("cbeta_id") == short_cbeta_id:
|
|
235
|
+
return r
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def verify_via_lookup(bridge, short_ids: list[str]) -> dict:
|
|
242
|
+
"""Try the batch lookup-cbeta endpoint. Returns {short_cbeta_id: internal_id}."""
|
|
243
|
+
result = {}
|
|
244
|
+
try:
|
|
245
|
+
ids_str = ",".join(short_ids)
|
|
246
|
+
resp = bridge.lookup_cbeta_ids(ids_str)
|
|
247
|
+
if isinstance(resp, dict):
|
|
248
|
+
mapping = resp.get("results") or resp.get("data") or resp
|
|
249
|
+
for sid in short_ids:
|
|
250
|
+
entry = mapping.get(sid)
|
|
251
|
+
if entry and isinstance(entry, dict):
|
|
252
|
+
result[sid] = entry.get("text_id") or entry.get("id")
|
|
253
|
+
elif entry and isinstance(entry, int):
|
|
254
|
+
result[sid] = entry
|
|
255
|
+
except Exception:
|
|
256
|
+
pass # Endpoint may not be implemented; fall back to search
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def verify_ids(bridge, cbeta_map: dict[str, list[str]], titles: dict[str, str]) -> dict[str, dict]:
|
|
261
|
+
"""Verify all CBETA IDs and return {full_cbeta_id: {text_id, short_id, title, ...}}.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
cbeta_map: {full_cbeta_id: [teacher_slugs]}
|
|
265
|
+
titles: {full_cbeta_id: title_from_meta} for search fallback
|
|
266
|
+
"""
|
|
267
|
+
results: dict[str, dict] = {}
|
|
268
|
+
|
|
269
|
+
# Build full -> short mapping
|
|
270
|
+
full_to_short = {}
|
|
271
|
+
short_to_full = {}
|
|
272
|
+
for full_id in cbeta_map:
|
|
273
|
+
short = full_to_short_cbeta(full_id)
|
|
274
|
+
if short:
|
|
275
|
+
full_to_short[full_id] = short
|
|
276
|
+
short_to_full[short] = full_id
|
|
277
|
+
|
|
278
|
+
# Try batch lookup first
|
|
279
|
+
short_ids = list(full_to_short.values())
|
|
280
|
+
lookup_result = verify_via_lookup(bridge, short_ids)
|
|
281
|
+
|
|
282
|
+
for full_id, short_id in sorted(full_to_short.items()):
|
|
283
|
+
if short_id in lookup_result:
|
|
284
|
+
results[full_id] = {
|
|
285
|
+
"text_id": lookup_result[short_id],
|
|
286
|
+
"short_cbeta_id": short_id,
|
|
287
|
+
"method": "lookup",
|
|
288
|
+
}
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
# Fallback: search by title
|
|
292
|
+
title = titles.get(full_id, "")
|
|
293
|
+
if title:
|
|
294
|
+
time.sleep(0.2) # Rate limit
|
|
295
|
+
match = verify_via_search(bridge, title, short_id)
|
|
296
|
+
if match:
|
|
297
|
+
results[full_id] = {
|
|
298
|
+
"text_id": match["id"],
|
|
299
|
+
"short_cbeta_id": short_id,
|
|
300
|
+
"title_zh": match.get("title_zh", ""),
|
|
301
|
+
"method": "search",
|
|
302
|
+
}
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
# Try direct get with the internal ID if it's numeric-ish
|
|
306
|
+
# Last resort: not found
|
|
307
|
+
results[full_id] = {
|
|
308
|
+
"text_id": None,
|
|
309
|
+
"short_cbeta_id": short_id,
|
|
310
|
+
"method": "not_found",
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return results
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def collect_titles_from_meta() -> dict[str, str]:
|
|
317
|
+
"""Collect {full_cbeta_id: title} from meta.json sources."""
|
|
318
|
+
titles = {}
|
|
319
|
+
for teacher in os.listdir(PREBUILT_DIR):
|
|
320
|
+
meta_path = os.path.join(PREBUILT_DIR, teacher, "meta.json")
|
|
321
|
+
if not os.path.isfile(meta_path):
|
|
322
|
+
continue
|
|
323
|
+
with open(meta_path, encoding="utf-8") as f:
|
|
324
|
+
meta = json.load(f)
|
|
325
|
+
for src in meta.get("sources", []):
|
|
326
|
+
if src.get("type") == "cbeta" and src.get("id") and src.get("title"):
|
|
327
|
+
titles[src["id"]] = src["title"]
|
|
328
|
+
return titles
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def fix_urls_in_file(
|
|
332
|
+
filepath: str, id_map: dict[str, str], dry_run: bool
|
|
333
|
+
) -> list[str]:
|
|
334
|
+
"""Replace CBETA IDs with internal text_ids in URLs. Returns list of changes."""
|
|
335
|
+
changes = []
|
|
336
|
+
with open(filepath, encoding="utf-8") as f:
|
|
337
|
+
content = f.read()
|
|
338
|
+
|
|
339
|
+
def replacer(m):
|
|
340
|
+
prefix = m.group(1)
|
|
341
|
+
old_id = m.group(2)
|
|
342
|
+
if old_id in id_map:
|
|
343
|
+
new_id = id_map[old_id]
|
|
344
|
+
rel = os.path.relpath(filepath, PROJECT_ROOT)
|
|
345
|
+
changes.append(f" {rel}: {old_id} -> {new_id}")
|
|
346
|
+
return prefix + new_id
|
|
347
|
+
return m.group(0)
|
|
348
|
+
|
|
349
|
+
new_content = FOJIN_URL_RE.sub(replacer, content)
|
|
350
|
+
|
|
351
|
+
if not dry_run and new_content != content:
|
|
352
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
353
|
+
f.write(new_content)
|
|
354
|
+
|
|
355
|
+
return changes
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _run_legacy_link_verification(*, fix: bool) -> int:
|
|
359
|
+
"""Preserve the historical FoJin CBETA URL audit and optional fixer."""
|
|
360
|
+
dry_run = not fix
|
|
361
|
+
|
|
362
|
+
print("=" * 60)
|
|
363
|
+
print("FoJin Source Verification Report")
|
|
364
|
+
print("=" * 60)
|
|
365
|
+
if dry_run:
|
|
366
|
+
print("Mode: DRY RUN (use --fix to apply changes)\n")
|
|
367
|
+
else:
|
|
368
|
+
print("Mode: FIX (writing changes to files)\n")
|
|
369
|
+
|
|
370
|
+
# Step 1: Collect CBETA IDs from meta.json
|
|
371
|
+
cbeta_map = collect_cbeta_ids()
|
|
372
|
+
all_cbeta_ids = sorted(cbeta_map.keys())
|
|
373
|
+
teacher_count = len(set(t for ts in cbeta_map.values() for t in ts))
|
|
374
|
+
print(f"[1/4] Found {len(all_cbeta_ids)} unique CBETA IDs across {teacher_count} teachers")
|
|
375
|
+
for cid in all_cbeta_ids:
|
|
376
|
+
short = full_to_short_cbeta(cid) or "?"
|
|
377
|
+
print(f" {cid} (-> {short}) <- {', '.join(cbeta_map[cid])}")
|
|
378
|
+
|
|
379
|
+
# Step 2: Collect all fojin.app URLs from files
|
|
380
|
+
url_map = collect_all_fojin_urls()
|
|
381
|
+
all_url_ids = sorted(url_map.keys())
|
|
382
|
+
total_urls = sum(len(v) for v in url_map.values())
|
|
383
|
+
print(f"\n[2/4] Found {total_urls} fojin.app URLs using {len(all_url_ids)} unique IDs")
|
|
384
|
+
|
|
385
|
+
# IDs in URLs but not in meta.json sources
|
|
386
|
+
extra_url_ids = set(all_url_ids) - set(all_cbeta_ids)
|
|
387
|
+
if extra_url_ids:
|
|
388
|
+
print(f" Extra IDs in URLs (not in meta.json sources):")
|
|
389
|
+
for eid in sorted(extra_url_ids):
|
|
390
|
+
locs = url_map[eid]
|
|
391
|
+
files = set(os.path.relpath(f, PROJECT_ROOT) for f, _ in locs)
|
|
392
|
+
print(f" {eid} in {', '.join(sorted(files))}")
|
|
393
|
+
|
|
394
|
+
# Combine: all unique CBETA-style IDs from both meta.json and URLs
|
|
395
|
+
all_ids = set(all_cbeta_ids)
|
|
396
|
+
for uid in all_url_ids:
|
|
397
|
+
if FULL_CBETA_RE.match(uid):
|
|
398
|
+
all_ids.add(uid)
|
|
399
|
+
|
|
400
|
+
# Non-CBETA IDs in URLs (e.g. suttacentral IDs, placeholder "123")
|
|
401
|
+
non_cbeta_url_ids = [uid for uid in all_url_ids if not FULL_CBETA_RE.match(uid)]
|
|
402
|
+
if non_cbeta_url_ids:
|
|
403
|
+
print(f" Non-CBETA IDs in URLs (skipped): {', '.join(non_cbeta_url_ids)}")
|
|
404
|
+
|
|
405
|
+
# Step 3: Verify with FoJin API
|
|
406
|
+
print(f"\n[3/4] Verifying {len(all_ids)} CBETA IDs against FoJin API...")
|
|
407
|
+
bridge = create_bridge()
|
|
408
|
+
|
|
409
|
+
if not bridge.test_connection():
|
|
410
|
+
print(" [ERROR] Cannot connect to FoJin API.")
|
|
411
|
+
print(" Set FOJIN_URL environment variable if using a custom instance.")
|
|
412
|
+
sys.exit(1)
|
|
413
|
+
print(" API connection OK")
|
|
414
|
+
|
|
415
|
+
# Build combined cbeta_map (include URL-only IDs)
|
|
416
|
+
combined_map = dict(cbeta_map)
|
|
417
|
+
for uid in all_url_ids:
|
|
418
|
+
if FULL_CBETA_RE.match(uid) and uid not in combined_map:
|
|
419
|
+
combined_map[uid] = ["(URL only)"]
|
|
420
|
+
|
|
421
|
+
titles = collect_titles_from_meta()
|
|
422
|
+
verified = verify_ids(bridge, combined_map, titles)
|
|
423
|
+
|
|
424
|
+
found = {k: v for k, v in verified.items() if v["text_id"] is not None}
|
|
425
|
+
not_found = {k: v for k, v in verified.items() if v["text_id"] is None}
|
|
426
|
+
|
|
427
|
+
print(f"\n Verified: {len(found)}/{len(verified)}")
|
|
428
|
+
for cid in sorted(found):
|
|
429
|
+
info = found[cid]
|
|
430
|
+
title = info.get("title_zh", titles.get(cid, ""))
|
|
431
|
+
print(f" [OK] {cid} -> text_id={info['text_id']} {title} ({info['method']})")
|
|
432
|
+
|
|
433
|
+
if not_found:
|
|
434
|
+
print(f"\n Not found in FoJin ({len(not_found)}):")
|
|
435
|
+
for cid in sorted(not_found):
|
|
436
|
+
teachers = combined_map.get(cid, ["?"])
|
|
437
|
+
print(f" [MISS] {cid} (-> {not_found[cid]['short_cbeta_id']}) used by: {', '.join(teachers)}")
|
|
438
|
+
|
|
439
|
+
# Step 4: Update URLs
|
|
440
|
+
# Build replacement map: full_cbeta_id -> str(internal_text_id)
|
|
441
|
+
id_replacement_map: dict[str, str] = {}
|
|
442
|
+
for full_id, info in found.items():
|
|
443
|
+
id_replacement_map[full_id] = str(info["text_id"])
|
|
444
|
+
|
|
445
|
+
action = "Would update" if dry_run else "Updating"
|
|
446
|
+
print(f"\n[4/4] {action} URLs...")
|
|
447
|
+
all_changes = []
|
|
448
|
+
|
|
449
|
+
files_to_fix = set()
|
|
450
|
+
for locations in url_map.values():
|
|
451
|
+
for fpath, _ in locations:
|
|
452
|
+
files_to_fix.add(fpath)
|
|
453
|
+
|
|
454
|
+
for fpath in sorted(files_to_fix):
|
|
455
|
+
changes = fix_urls_in_file(fpath, id_replacement_map, dry_run)
|
|
456
|
+
all_changes.extend(changes)
|
|
457
|
+
|
|
458
|
+
if all_changes:
|
|
459
|
+
for c in all_changes:
|
|
460
|
+
print(c)
|
|
461
|
+
verb = "would be made" if dry_run else "applied"
|
|
462
|
+
print(f"\n Total: {len(all_changes)} URL replacements {verb}")
|
|
463
|
+
else:
|
|
464
|
+
print(" No URL replacements needed")
|
|
465
|
+
|
|
466
|
+
# Summary
|
|
467
|
+
print("\n" + "=" * 60)
|
|
468
|
+
print("Summary")
|
|
469
|
+
print("=" * 60)
|
|
470
|
+
print(f" CBETA IDs in meta.json: {len(all_cbeta_ids)}")
|
|
471
|
+
print(f" CBETA IDs in URLs: {len([u for u in all_url_ids if FULL_CBETA_RE.match(u)])}")
|
|
472
|
+
print(f" Total unique CBETA IDs: {len(all_ids)}")
|
|
473
|
+
print(f" Verified in FoJin: {len(found)}")
|
|
474
|
+
print(f" Not found in FoJin: {len(not_found)}")
|
|
475
|
+
print(f" URL replacements: {len(all_changes)}")
|
|
476
|
+
if dry_run and all_changes:
|
|
477
|
+
print("\n Run with --fix to apply changes.")
|
|
478
|
+
|
|
479
|
+
return 0
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def main(argv: list[str] | None = None) -> int:
|
|
483
|
+
parser = argparse.ArgumentParser(
|
|
484
|
+
description="Validate declared source manifests or audit legacy FoJin links"
|
|
485
|
+
)
|
|
486
|
+
modes = parser.add_mutually_exclusive_group()
|
|
487
|
+
modes.add_argument(
|
|
488
|
+
"--check-links",
|
|
489
|
+
metavar="JSON",
|
|
490
|
+
help="offline-check a collected JSON source manifest",
|
|
491
|
+
)
|
|
492
|
+
modes.add_argument(
|
|
493
|
+
"--final-check",
|
|
494
|
+
metavar="PERSONA_DIR",
|
|
495
|
+
help="offline-check a generated persona and its final meta.json",
|
|
496
|
+
)
|
|
497
|
+
modes.add_argument(
|
|
498
|
+
"--fix",
|
|
499
|
+
action="store_true",
|
|
500
|
+
help="run the legacy online CBETA URL audit and apply replacements",
|
|
501
|
+
)
|
|
502
|
+
args = parser.parse_args(argv)
|
|
503
|
+
|
|
504
|
+
if args.check_links:
|
|
505
|
+
return _run_declared_source_check(args.check_links, final=False)
|
|
506
|
+
if args.final_check:
|
|
507
|
+
return _run_declared_source_check(args.final_check, final=True)
|
|
508
|
+
return _run_legacy_link_verification(fix=args.fix)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
if __name__ == "__main__":
|
|
512
|
+
sys.exit(main())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Version Manager — archives and rolls back teacher skill versions.
|
|
3
|
+
Adapted from colleague-skill's version_manager.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
MAX_VERSIONS = 10
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def list_versions(teacher_dir: str) -> list:
|
|
15
|
+
"""List all archived versions of a teacher skill."""
|
|
16
|
+
versions_dir = os.path.join(teacher_dir, "versions")
|
|
17
|
+
if not os.path.exists(versions_dir):
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
versions = []
|
|
21
|
+
for entry in sorted(os.listdir(versions_dir)):
|
|
22
|
+
entry_path = os.path.join(versions_dir, entry)
|
|
23
|
+
if os.path.isdir(entry_path) and entry.startswith("v"):
|
|
24
|
+
files = os.listdir(entry_path)
|
|
25
|
+
mtime = os.path.getmtime(entry_path)
|
|
26
|
+
versions.append({
|
|
27
|
+
"version": entry[1:],
|
|
28
|
+
"archived_at": datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M"),
|
|
29
|
+
"files": files,
|
|
30
|
+
})
|
|
31
|
+
return versions
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rollback(teacher_dir: str, target_version: str) -> bool:
|
|
35
|
+
"""Roll back a teacher skill to a previous version. Backs up current state first."""
|
|
36
|
+
target_dir = os.path.join(teacher_dir, "versions", f"v{target_version}")
|
|
37
|
+
if not os.path.isdir(target_dir):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
meta_path = os.path.join(teacher_dir, "meta.json")
|
|
41
|
+
current_version = "unknown"
|
|
42
|
+
if os.path.exists(meta_path):
|
|
43
|
+
with open(meta_path, "r", encoding="utf-8") as f:
|
|
44
|
+
meta = json.load(f)
|
|
45
|
+
current_version = meta.get("version", "unknown")
|
|
46
|
+
backup_dir = os.path.join(teacher_dir, "versions", f"v{current_version}_before_rollback")
|
|
47
|
+
os.makedirs(backup_dir, exist_ok=True)
|
|
48
|
+
for fname in ["SKILL.md", "teaching.md", "voice.md", "meta.json"]:
|
|
49
|
+
src = os.path.join(teacher_dir, fname)
|
|
50
|
+
if os.path.exists(src):
|
|
51
|
+
shutil.copy2(src, backup_dir)
|
|
52
|
+
|
|
53
|
+
for fname in ["SKILL.md", "teaching.md", "voice.md", "meta.json"]:
|
|
54
|
+
src = os.path.join(target_dir, fname)
|
|
55
|
+
if os.path.exists(src):
|
|
56
|
+
shutil.copy2(src, os.path.join(teacher_dir, fname))
|
|
57
|
+
|
|
58
|
+
if os.path.exists(meta_path):
|
|
59
|
+
with open(meta_path, "r", encoding="utf-8") as f:
|
|
60
|
+
meta = json.load(f)
|
|
61
|
+
meta["rollback_from"] = current_version
|
|
62
|
+
meta["updated_at"] = datetime.now().strftime("%Y-%m-%d")
|
|
63
|
+
with open(meta_path, "w", encoding="utf-8") as f:
|
|
64
|
+
json.dump(meta, f, ensure_ascii=False, indent=2)
|
|
65
|
+
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cleanup_old_versions(teacher_dir: str) -> int:
|
|
70
|
+
"""Remove old versions beyond MAX_VERSIONS limit."""
|
|
71
|
+
versions_dir = os.path.join(teacher_dir, "versions")
|
|
72
|
+
if not os.path.exists(versions_dir):
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
entries = []
|
|
76
|
+
for entry in os.listdir(versions_dir):
|
|
77
|
+
entry_path = os.path.join(versions_dir, entry)
|
|
78
|
+
if os.path.isdir(entry_path):
|
|
79
|
+
entries.append((entry_path, os.path.getmtime(entry_path)))
|
|
80
|
+
|
|
81
|
+
entries.sort(key=lambda x: x[1], reverse=True)
|
|
82
|
+
|
|
83
|
+
removed = 0
|
|
84
|
+
for path, _ in entries[MAX_VERSIONS:]:
|
|
85
|
+
shutil.rmtree(path)
|
|
86
|
+
removed += 1
|
|
87
|
+
|
|
88
|
+
return removed
|
|
File without changes
|