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,393 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate that lore_triggers[].content quotes can be located verbatim
|
|
3
|
+
(or with high similarity) in the master's own sources/*-excerpts.md.
|
|
4
|
+
|
|
5
|
+
Background
|
|
6
|
+
----------
|
|
7
|
+
PR #32 introduced the lore_triggers schema. During self-review one entry
|
|
8
|
+
was caught and removed where the `content` quote was fabricated and falsely
|
|
9
|
+
attributed to T48n2008. This validator exists so the next fabrication is
|
|
10
|
+
caught by CI, not by luck.
|
|
11
|
+
|
|
12
|
+
Algorithm
|
|
13
|
+
---------
|
|
14
|
+
For each prebuilt/master-<slug>/meta.json:
|
|
15
|
+
- For each lore_triggers[] entry:
|
|
16
|
+
- Extract the quotation portion of `content` (strip the editorial
|
|
17
|
+
gloss that follows the em-dash "——" if present)
|
|
18
|
+
- Identify candidate excerpts files in this master's sources/ dir
|
|
19
|
+
(preferring those whose name or body contains a normalized form
|
|
20
|
+
of the source_ref CBETA id; otherwise scan all *-excerpts.md)
|
|
21
|
+
- Normalize both texts (drop punctuation/whitespace, T<->S Han, …)
|
|
22
|
+
- PASS if any of:
|
|
23
|
+
* longest common substring >= 60 chars
|
|
24
|
+
* difflib.SequenceMatcher ratio >= 0.75
|
|
25
|
+
Otherwise FAIL and report best candidate file + best ratio.
|
|
26
|
+
|
|
27
|
+
Mode
|
|
28
|
+
----
|
|
29
|
+
By default this is *advisory* — it prints findings but exits 0 so it does
|
|
30
|
+
not block CI on day one. Run with `--strict` to make failures hard exits
|
|
31
|
+
(used locally and from a follow-up CI gate after the grace window).
|
|
32
|
+
|
|
33
|
+
Plan: advisory through v0.8.x, hard gate from v0.9.0.
|
|
34
|
+
|
|
35
|
+
Usage
|
|
36
|
+
-----
|
|
37
|
+
python scripts/validate-lore-triggers-content.py
|
|
38
|
+
python scripts/validate-lore-triggers-content.py --strict
|
|
39
|
+
python scripts/validate-lore-triggers-content.py --master master-huineng
|
|
40
|
+
python scripts/validate-lore-triggers-content.py --json
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import json
|
|
46
|
+
import re
|
|
47
|
+
import sys
|
|
48
|
+
from difflib import SequenceMatcher
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
|
|
52
|
+
|
|
53
|
+
# Thresholds — calibrated against the 7 lore_triggers shipped in PR #32.
|
|
54
|
+
# A pass requires EITHER an absolute LCS floor OR a relative LCS coverage
|
|
55
|
+
# OR a high SequenceMatcher ratio. Short quotes (≈45 chars) need to match
|
|
56
|
+
# nearly verbatim; longer quotes (80+) just need a 40-char window.
|
|
57
|
+
MIN_LCS_ABS = 40 # absolute floor — longest common substring chars
|
|
58
|
+
MIN_LCS_FRAC = 0.85 # OR: LCS must cover ≥85% of the normalized quote
|
|
59
|
+
MIN_RATIO = 0.75 # OR: SequenceMatcher ratio across the file
|
|
60
|
+
|
|
61
|
+
# Drop these chars during normalization. Includes ASCII + common CJK
|
|
62
|
+
# punctuation, half- and full-width spaces, and a few special markers.
|
|
63
|
+
PUNCT_PATTERN = re.compile(
|
|
64
|
+
r"[\s "
|
|
65
|
+
r",。、;:!?「」『』()【】《》〈〉…—–\-·"
|
|
66
|
+
r",.;:!?()\[\]{}<>\"'`~@#\$%\^&\*_+=|\\/]"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Minimal Traditional -> Simplified table covering the chars that actually
|
|
70
|
+
# show up in our excerpts vs meta quotes. Keeping this hand-curated avoids
|
|
71
|
+
# adding opencc as a dep. Extend as new false-negatives surface.
|
|
72
|
+
T2S = {
|
|
73
|
+
"於": "于", "爾": "尔", "後": "后", "個": "个", "們": "们",
|
|
74
|
+
"這": "这", "麼": "么", "說": "说", "話": "话", "對": "对",
|
|
75
|
+
"問": "问", "見": "见", "現": "现", "實": "实", "經": "经",
|
|
76
|
+
"聖": "圣", "賢": "贤", "覺": "觉", "悟": "悟", "靈": "灵",
|
|
77
|
+
"魂": "魂", "體": "体", "氣": "气", "風": "风", "雲": "云",
|
|
78
|
+
"電": "电", "話": "话", "書": "书", "讀": "读", "寫": "写",
|
|
79
|
+
"聽": "听", "聞": "闻", "嗎": "吗", "啟": "启", "輪": "轮",
|
|
80
|
+
"華": "华", "藏": "藏", "識": "识", "種": "种", "葉": "叶",
|
|
81
|
+
"場": "场", "進": "进", "達": "达", "過": "过", "來": "来",
|
|
82
|
+
"車": "车", "間": "间", "問": "问", "離": "离", "邊": "边",
|
|
83
|
+
"處": "处", "顯": "显", "標": "标", "點": "点", "稱": "称",
|
|
84
|
+
"讚": "赞", "勸": "劝", "請": "请", "餘": "余", "禪": "禅",
|
|
85
|
+
"閉": "闭", "開": "开", "關": "关", "鎖": "锁", "頓": "顿",
|
|
86
|
+
"漸": "渐", "悟": "悟", "戒": "戒", "戀": "恋", "夢": "梦",
|
|
87
|
+
"斷": "断", "點": "点", "靜": "静", "動": "动", "亂": "乱",
|
|
88
|
+
"歲": "岁", "時": "时", "節": "节", "義": "义", "禮": "礼",
|
|
89
|
+
"孫": "孙", "誰": "谁", "與": "与", "從": "从", "貴": "贵",
|
|
90
|
+
"賤": "贱", "誤": "误", "繁": "繁", "簡": "简", "歸": "归",
|
|
91
|
+
"鐘": "钟", "響": "响", "陽": "阳", "陰": "阴",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def normalize(text: str) -> str:
|
|
96
|
+
"""Drop punctuation + whitespace, fold trad->simp."""
|
|
97
|
+
text = "".join(T2S.get(c, c) for c in text)
|
|
98
|
+
text = PUNCT_PATTERN.sub("", text)
|
|
99
|
+
return text
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def extract_quotation(content: str) -> str:
|
|
103
|
+
"""Lore_triggers content is typically: <quote>——<editorial gloss>
|
|
104
|
+
|
|
105
|
+
Strip everything from the em-dash onwards so we only compare the
|
|
106
|
+
actual scripture quotation against the excerpts file.
|
|
107
|
+
"""
|
|
108
|
+
# The em-dash separator may be a single 「——」 or 「——」 followed by
|
|
109
|
+
# 《text》 etc. Some entries lack the gloss; in that case return the
|
|
110
|
+
# whole content untouched.
|
|
111
|
+
if "——" in content:
|
|
112
|
+
return content.split("——", 1)[0]
|
|
113
|
+
if "──" in content:
|
|
114
|
+
return content.split("──", 1)[0]
|
|
115
|
+
return content
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def longest_common_substring_len(a: str, b: str) -> int:
|
|
119
|
+
"""Return length of the longest common substring of normalized a,b.
|
|
120
|
+
|
|
121
|
+
Uses SequenceMatcher's find_longest_match which runs in roughly
|
|
122
|
+
O(len(a)*len(b)) — fine for our hundred-char strings.
|
|
123
|
+
"""
|
|
124
|
+
if not a or not b:
|
|
125
|
+
return 0
|
|
126
|
+
sm = SequenceMatcher(None, a, b, autojunk=False)
|
|
127
|
+
m = sm.find_longest_match(0, len(a), 0, len(b))
|
|
128
|
+
return m.size
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def similarity_ratio(a: str, b: str) -> float:
|
|
132
|
+
"""SequenceMatcher ratio on normalized strings (windowed for speed).
|
|
133
|
+
|
|
134
|
+
`b` (the haystack / whole excerpts file) is typically much larger
|
|
135
|
+
than `a`. Compare against a sliding window of size ~2x|a| so the
|
|
136
|
+
ratio isn't drowned by the rest of the file.
|
|
137
|
+
"""
|
|
138
|
+
if not a or not b:
|
|
139
|
+
return 0.0
|
|
140
|
+
if len(b) <= 2 * len(a):
|
|
141
|
+
return SequenceMatcher(None, a, b, autojunk=False).ratio()
|
|
142
|
+
best = 0.0
|
|
143
|
+
win = 2 * len(a)
|
|
144
|
+
step = max(1, len(a) // 2)
|
|
145
|
+
for i in range(0, len(b) - win + 1, step):
|
|
146
|
+
r = SequenceMatcher(None, a, b[i : i + win], autojunk=False).ratio()
|
|
147
|
+
if r > best:
|
|
148
|
+
best = r
|
|
149
|
+
# Early exit if we already pass the bar comfortably
|
|
150
|
+
if best >= 0.95:
|
|
151
|
+
break
|
|
152
|
+
return best
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _candidate_files(
|
|
156
|
+
master_dir: Path, source_ref: str, *, include_references: bool = False
|
|
157
|
+
) -> list[Path]:
|
|
158
|
+
"""Return excerpts files ranked by likely relevance to source_ref.
|
|
159
|
+
|
|
160
|
+
Strategy: prefer files in sources/ whose name or body contains a
|
|
161
|
+
normalized form of the CBETA id (e.g. T46n1911 -> also try T1911
|
|
162
|
+
short form). Fall back to all *-excerpts.md files. If
|
|
163
|
+
include_references=True, also append references/*.md as a soft
|
|
164
|
+
secondary corpus (used when the primary excerpts check fails — a
|
|
165
|
+
match there means the quote is real but excerpts/ is incomplete).
|
|
166
|
+
"""
|
|
167
|
+
sources = master_dir / "sources"
|
|
168
|
+
if not sources.exists():
|
|
169
|
+
return []
|
|
170
|
+
all_files = sorted(sources.glob("*-excerpts.md"))
|
|
171
|
+
|
|
172
|
+
cbeta_prefix = source_ref.split("#", 1)[0]
|
|
173
|
+
# Build keys to look for in file body: long form (T46n1911) and a
|
|
174
|
+
# short form stripping the "n<digits>" segment (T1911).
|
|
175
|
+
keys = {cbeta_prefix}
|
|
176
|
+
m = re.match(r"^(T)\d+(n\d+)$", cbeta_prefix)
|
|
177
|
+
if m:
|
|
178
|
+
# T46n1911 -> T1911
|
|
179
|
+
keys.add(m.group(1) + cbeta_prefix.split("n", 1)[1])
|
|
180
|
+
|
|
181
|
+
ranked: list[tuple[int, Path]] = []
|
|
182
|
+
for f in all_files:
|
|
183
|
+
body = f.read_text(encoding="utf-8", errors="ignore")
|
|
184
|
+
score = 0
|
|
185
|
+
for k in keys:
|
|
186
|
+
if k in body:
|
|
187
|
+
score += 2
|
|
188
|
+
if k in f.name:
|
|
189
|
+
score += 1
|
|
190
|
+
ranked.append((score, f))
|
|
191
|
+
ranked.sort(key=lambda x: -x[0])
|
|
192
|
+
sources_list = [p for _, p in ranked] or all_files
|
|
193
|
+
|
|
194
|
+
if include_references:
|
|
195
|
+
refs = master_dir / "references"
|
|
196
|
+
if refs.exists():
|
|
197
|
+
return sources_list + sorted(refs.glob("*.md"))
|
|
198
|
+
return sources_list
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def check_entry(
|
|
202
|
+
master_dir: Path,
|
|
203
|
+
entry: dict,
|
|
204
|
+
entry_idx: int,
|
|
205
|
+
) -> dict:
|
|
206
|
+
"""Check a single lore_triggers entry. Returns a result dict."""
|
|
207
|
+
content = entry.get("content", "") or ""
|
|
208
|
+
source_ref = entry.get("source_ref", "") or ""
|
|
209
|
+
quote = extract_quotation(content)
|
|
210
|
+
quote_norm = normalize(quote)
|
|
211
|
+
|
|
212
|
+
# Pass thresholds depend on quote length: a short quote must be matched
|
|
213
|
+
# almost verbatim (frac of its own length); a long quote can satisfy
|
|
214
|
+
# the absolute window of MIN_LCS_ABS chars.
|
|
215
|
+
needed_lcs = min(MIN_LCS_ABS, max(1, int(len(quote_norm) * MIN_LCS_FRAC)))
|
|
216
|
+
|
|
217
|
+
def _scan(files: list[Path]) -> tuple[Path | None, int, float]:
|
|
218
|
+
bf, blcs, br = None, 0, 0.0
|
|
219
|
+
for f in files:
|
|
220
|
+
body = f.read_text(encoding="utf-8", errors="ignore")
|
|
221
|
+
body_norm = normalize(body)
|
|
222
|
+
lcs = longest_common_substring_len(quote_norm, body_norm)
|
|
223
|
+
ratio = similarity_ratio(quote_norm, body_norm)
|
|
224
|
+
if lcs > blcs or (lcs == blcs and ratio > br):
|
|
225
|
+
blcs, br, bf = lcs, ratio, f
|
|
226
|
+
if lcs >= needed_lcs or ratio >= MIN_RATIO:
|
|
227
|
+
break
|
|
228
|
+
return bf, blcs, br
|
|
229
|
+
|
|
230
|
+
# Primary check: only files inside sources/
|
|
231
|
+
primary_files = _candidate_files(master_dir, source_ref)
|
|
232
|
+
best_file, best_lcs, best_ratio = _scan(primary_files)
|
|
233
|
+
passed_in_sources = (best_lcs >= needed_lcs) or (best_ratio >= MIN_RATIO)
|
|
234
|
+
|
|
235
|
+
# Secondary check: also look in references/. A match here means the
|
|
236
|
+
# quote is real but the sources/ excerpts file does not contain it;
|
|
237
|
+
# advisory output should flag this so the author can extend excerpts.
|
|
238
|
+
found_in_references = False
|
|
239
|
+
ref_file = None
|
|
240
|
+
if not passed_in_sources:
|
|
241
|
+
secondary_files = _candidate_files(
|
|
242
|
+
master_dir, source_ref, include_references=True
|
|
243
|
+
)
|
|
244
|
+
# Only scan the references-side files we did not already check
|
|
245
|
+
new_files = [f for f in secondary_files if f not in primary_files]
|
|
246
|
+
rf, rlcs, rr = _scan(new_files)
|
|
247
|
+
if (rlcs >= needed_lcs) or (rr >= MIN_RATIO):
|
|
248
|
+
found_in_references = True
|
|
249
|
+
ref_file = rf
|
|
250
|
+
if rlcs > best_lcs or (rlcs == best_lcs and rr > best_ratio):
|
|
251
|
+
best_file, best_lcs, best_ratio = rf, rlcs, rr
|
|
252
|
+
|
|
253
|
+
# "passed" gating: true if quote is real (in either sources or
|
|
254
|
+
# references). The "in_sources_only" sub-field lets CI distinguish
|
|
255
|
+
# strict-pass (sources) from advisory-pass (references-only).
|
|
256
|
+
passed = passed_in_sources or found_in_references
|
|
257
|
+
return {
|
|
258
|
+
"master": master_dir.name,
|
|
259
|
+
"entry_idx": entry_idx,
|
|
260
|
+
"source_ref": source_ref,
|
|
261
|
+
"quote_len": len(quote_norm),
|
|
262
|
+
"needed_lcs": needed_lcs,
|
|
263
|
+
"best_file": best_file.name if best_file else None,
|
|
264
|
+
"best_lcs": best_lcs,
|
|
265
|
+
"best_ratio": round(best_ratio, 3),
|
|
266
|
+
"passed": passed,
|
|
267
|
+
"passed_in_sources": passed_in_sources,
|
|
268
|
+
"in_references_only": found_in_references and not passed_in_sources,
|
|
269
|
+
"ref_file": ref_file.name if ref_file else None,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def validate(
|
|
274
|
+
prebuilt_dir: Path = PREBUILT_DIR,
|
|
275
|
+
master_filter: str | None = None,
|
|
276
|
+
) -> list[dict]:
|
|
277
|
+
"""Return list of result dicts (one per lore_triggers entry checked)."""
|
|
278
|
+
results: list[dict] = []
|
|
279
|
+
for d in sorted(prebuilt_dir.iterdir()):
|
|
280
|
+
if not d.is_dir():
|
|
281
|
+
continue
|
|
282
|
+
if master_filter and d.name != master_filter:
|
|
283
|
+
continue
|
|
284
|
+
meta_path = d / "meta.json"
|
|
285
|
+
if not meta_path.exists():
|
|
286
|
+
continue
|
|
287
|
+
try:
|
|
288
|
+
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
289
|
+
except json.JSONDecodeError:
|
|
290
|
+
continue
|
|
291
|
+
if data.get("kind") == "meta-skill":
|
|
292
|
+
continue
|
|
293
|
+
lore = data.get("lore_triggers", []) or []
|
|
294
|
+
for i, entry in enumerate(lore):
|
|
295
|
+
results.append(check_entry(d, entry, i))
|
|
296
|
+
return results
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _format_human(results: list[dict]) -> tuple[str, int]:
|
|
300
|
+
fails = [r for r in results if not r["passed"]]
|
|
301
|
+
soft = [r for r in results if r.get("in_references_only")]
|
|
302
|
+
lines: list[str] = []
|
|
303
|
+
if not results:
|
|
304
|
+
lines.append("No lore_triggers entries found to check.")
|
|
305
|
+
return "\n".join(lines), 0
|
|
306
|
+
|
|
307
|
+
lines.append(
|
|
308
|
+
f"Checked {len(results)} lore_triggers entries across "
|
|
309
|
+
f"{len({r['master'] for r in results})} masters."
|
|
310
|
+
)
|
|
311
|
+
lines.append("")
|
|
312
|
+
lines.append("Similarity distribution (per entry):")
|
|
313
|
+
for r in results:
|
|
314
|
+
if not r["passed"]:
|
|
315
|
+
marker = "FAIL"
|
|
316
|
+
elif r.get("in_references_only"):
|
|
317
|
+
marker = "WARN"
|
|
318
|
+
else:
|
|
319
|
+
marker = "PASS"
|
|
320
|
+
lines.append(
|
|
321
|
+
f" [{marker}] {r['master']} #{r['entry_idx']} "
|
|
322
|
+
f"({r['source_ref']}) — file={r['best_file']} "
|
|
323
|
+
f"quote_len={r['quote_len']} lcs={r['best_lcs']} "
|
|
324
|
+
f"(need {r['needed_lcs']}) ratio={r['best_ratio']}"
|
|
325
|
+
)
|
|
326
|
+
if soft:
|
|
327
|
+
lines.append("")
|
|
328
|
+
lines.append(
|
|
329
|
+
f"{len(soft)} entry/entries matched only in references/ "
|
|
330
|
+
f"(quote is real but sources/ excerpts file does not contain it; "
|
|
331
|
+
f"consider extending the excerpts):"
|
|
332
|
+
)
|
|
333
|
+
for r in soft:
|
|
334
|
+
lines.append(
|
|
335
|
+
f" - {r['master']} entry[{r['entry_idx']}] source_ref="
|
|
336
|
+
f"{r['source_ref']}: match in references/{r['ref_file']}"
|
|
337
|
+
)
|
|
338
|
+
if fails:
|
|
339
|
+
lines.append("")
|
|
340
|
+
lines.append(f"{len(fails)} entry/entries did not meet thresholds:")
|
|
341
|
+
lines.append(
|
|
342
|
+
f" thresholds: longest_common_substring >= "
|
|
343
|
+
f"min({MIN_LCS_ABS}, {MIN_LCS_FRAC}*quote_len) chars "
|
|
344
|
+
f"OR SequenceMatcher ratio >= {MIN_RATIO}"
|
|
345
|
+
)
|
|
346
|
+
for r in fails:
|
|
347
|
+
lines.append(
|
|
348
|
+
f" - {r['master']} entry[{r['entry_idx']}] source_ref="
|
|
349
|
+
f"{r['source_ref']}: no high-similarity match "
|
|
350
|
+
f"(best file={r['best_file']}, lcs={r['best_lcs']} "
|
|
351
|
+
f"need {r['needed_lcs']}, ratio={r['best_ratio']})"
|
|
352
|
+
)
|
|
353
|
+
return "\n".join(lines), len(fails)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def main() -> int:
|
|
357
|
+
parser = argparse.ArgumentParser(
|
|
358
|
+
description="Validate lore_triggers content against sources excerpts."
|
|
359
|
+
)
|
|
360
|
+
parser.add_argument("--master", type=str, help="Only check one master dir")
|
|
361
|
+
parser.add_argument(
|
|
362
|
+
"--strict",
|
|
363
|
+
action="store_true",
|
|
364
|
+
help="Exit non-zero on any FAIL (default: advisory, always exit 0)",
|
|
365
|
+
)
|
|
366
|
+
parser.add_argument("--json", action="store_true", help="JSON output")
|
|
367
|
+
args = parser.parse_args()
|
|
368
|
+
|
|
369
|
+
results = validate(PREBUILT_DIR, master_filter=args.master)
|
|
370
|
+
|
|
371
|
+
if args.json:
|
|
372
|
+
print(json.dumps({"results": results}, ensure_ascii=False, indent=2))
|
|
373
|
+
else:
|
|
374
|
+
text, _ = _format_human(results)
|
|
375
|
+
print(text)
|
|
376
|
+
|
|
377
|
+
fails = [r for r in results if not r["passed"]]
|
|
378
|
+
if fails:
|
|
379
|
+
if args.strict:
|
|
380
|
+
return 1
|
|
381
|
+
# Advisory mode: print a banner and exit 0 so CI does not block.
|
|
382
|
+
if not args.json:
|
|
383
|
+
print()
|
|
384
|
+
print(
|
|
385
|
+
"ADVISORY: lore_triggers content validator is in advisory "
|
|
386
|
+
"mode for v0.8.x. It will become a hard CI gate in v0.9. "
|
|
387
|
+
"Rerun with --strict locally to reproduce a hard failure."
|
|
388
|
+
)
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
if __name__ == "__main__":
|
|
393
|
+
sys.exit(main())
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate the persona-fidelity schema across master meta.json files.
|
|
3
|
+
|
|
4
|
+
v0.8 introduces three new fields on each single-master meta.json:
|
|
5
|
+
|
|
6
|
+
- signature_phrases (required) : list[str], 3-7 non-blank entries — anchors
|
|
7
|
+
used by fidelity tests to detect off-voice drift.
|
|
8
|
+
- style (required) : dict with exactly three string keys
|
|
9
|
+
{all, qa, monologue}, each 30-80 chars (zh-Hans expected).
|
|
10
|
+
- lore_triggers (optional) : list[dict] of conditional snippet
|
|
11
|
+
injections. Each entry needs `keys` (non-empty list) + `content`
|
|
12
|
+
(80-300 chars) + `source_ref` (must resolve to a real id in this
|
|
13
|
+
master's own sources[].id, optionally with a `#anchor` suffix).
|
|
14
|
+
If `secondary_keys` is present, `selective` must be true.
|
|
15
|
+
|
|
16
|
+
Meta-skills (compare-masters / master-debate / master-curriculum) carry
|
|
17
|
+
no meta.json so they are naturally skipped.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
python3 scripts/validate-persona-fidelity.py
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
|
|
29
|
+
|
|
30
|
+
SIGNATURE_MIN = 3
|
|
31
|
+
SIGNATURE_MAX = 7
|
|
32
|
+
|
|
33
|
+
STYLE_KEYS = {"all", "qa", "monologue"}
|
|
34
|
+
STYLE_MIN = 30
|
|
35
|
+
STYLE_MAX = 80
|
|
36
|
+
|
|
37
|
+
LORE_CONTENT_MIN = 80
|
|
38
|
+
LORE_CONTENT_MAX = 300
|
|
39
|
+
LORE_REQUIRED_KEYS = {"keys", "content", "source_ref"}
|
|
40
|
+
LORE_OPTIONAL_KEYS = {"secondary_keys", "selective"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load(meta_path: Path) -> dict:
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(meta_path.read_text(encoding="utf-8"))
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_signature_phrases(slug: str, data: dict) -> list[str]:
|
|
51
|
+
errors: list[str] = []
|
|
52
|
+
if "signature_phrases" not in data:
|
|
53
|
+
return [f"{slug}: missing required field 'signature_phrases'"]
|
|
54
|
+
phrases = data["signature_phrases"]
|
|
55
|
+
if not isinstance(phrases, list):
|
|
56
|
+
return [f"{slug}: signature_phrases must be a list, got {type(phrases).__name__}"]
|
|
57
|
+
n = len(phrases)
|
|
58
|
+
if n < SIGNATURE_MIN or n > SIGNATURE_MAX:
|
|
59
|
+
errors.append(
|
|
60
|
+
f"{slug}: signature_phrases length {n} out of "
|
|
61
|
+
f"[{SIGNATURE_MIN}, {SIGNATURE_MAX}]"
|
|
62
|
+
)
|
|
63
|
+
for i, p in enumerate(phrases):
|
|
64
|
+
if not isinstance(p, str) or not p.strip():
|
|
65
|
+
errors.append(f"{slug}: signature_phrases[{i}] must be a non-blank string")
|
|
66
|
+
return errors
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _check_style(slug: str, data: dict) -> list[str]:
|
|
70
|
+
errors: list[str] = []
|
|
71
|
+
if "style" not in data:
|
|
72
|
+
return [f"{slug}: missing required field 'style'"]
|
|
73
|
+
style = data["style"]
|
|
74
|
+
if not isinstance(style, dict):
|
|
75
|
+
return [f"{slug}: style must be an object, got {type(style).__name__}"]
|
|
76
|
+
keys = set(style.keys())
|
|
77
|
+
missing = STYLE_KEYS - keys
|
|
78
|
+
extra = keys - STYLE_KEYS
|
|
79
|
+
for k in sorted(missing):
|
|
80
|
+
errors.append(f"{slug}: style missing required key '{k}'")
|
|
81
|
+
for k in sorted(extra):
|
|
82
|
+
errors.append(f"{slug}: style has unexpected key '{k}'")
|
|
83
|
+
for k in sorted(STYLE_KEYS & keys):
|
|
84
|
+
v = style[k]
|
|
85
|
+
if not isinstance(v, str):
|
|
86
|
+
errors.append(
|
|
87
|
+
f"{slug}: style.{k} must be a string, got {type(v).__name__}"
|
|
88
|
+
)
|
|
89
|
+
continue
|
|
90
|
+
length = len(v)
|
|
91
|
+
if length < STYLE_MIN or length > STYLE_MAX:
|
|
92
|
+
errors.append(
|
|
93
|
+
f"{slug}: style.{k} length {length} out of [{STYLE_MIN}, {STYLE_MAX}]"
|
|
94
|
+
)
|
|
95
|
+
return errors
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_lore_triggers(slug: str, data: dict, source_ids: set[str]) -> list[str]:
|
|
99
|
+
errors: list[str] = []
|
|
100
|
+
if "lore_triggers" not in data:
|
|
101
|
+
return errors
|
|
102
|
+
triggers = data["lore_triggers"]
|
|
103
|
+
if not isinstance(triggers, list):
|
|
104
|
+
return [f"{slug}: lore_triggers must be a list, got {type(triggers).__name__}"]
|
|
105
|
+
for i, entry in enumerate(triggers):
|
|
106
|
+
prefix = f"{slug}.lore_triggers[{i}]"
|
|
107
|
+
if not isinstance(entry, dict):
|
|
108
|
+
errors.append(f"{prefix}: entry must be an object")
|
|
109
|
+
continue
|
|
110
|
+
# required keys
|
|
111
|
+
for k in sorted(LORE_REQUIRED_KEYS):
|
|
112
|
+
if k not in entry:
|
|
113
|
+
errors.append(f"{prefix}: missing required key '{k}'")
|
|
114
|
+
# unknown keys (allow required + optional)
|
|
115
|
+
for k in sorted(entry.keys() - LORE_REQUIRED_KEYS - LORE_OPTIONAL_KEYS):
|
|
116
|
+
errors.append(f"{prefix}: unexpected key '{k}'")
|
|
117
|
+
# keys
|
|
118
|
+
keys = entry.get("keys")
|
|
119
|
+
if keys is not None:
|
|
120
|
+
if not isinstance(keys, list) or not keys:
|
|
121
|
+
errors.append(f"{prefix}: keys must be a non-empty list")
|
|
122
|
+
else:
|
|
123
|
+
for j, k in enumerate(keys):
|
|
124
|
+
if not isinstance(k, str) or not k.strip():
|
|
125
|
+
errors.append(
|
|
126
|
+
f"{prefix}: keys[{j}] must be a non-blank string"
|
|
127
|
+
)
|
|
128
|
+
# secondary_keys + selective
|
|
129
|
+
sec = entry.get("secondary_keys")
|
|
130
|
+
if sec is not None:
|
|
131
|
+
if not isinstance(sec, list):
|
|
132
|
+
errors.append(f"{prefix}: secondary_keys must be a list")
|
|
133
|
+
else:
|
|
134
|
+
for j, k in enumerate(sec):
|
|
135
|
+
if not isinstance(k, str) or not k.strip():
|
|
136
|
+
errors.append(
|
|
137
|
+
f"{prefix}: secondary_keys[{j}] must be a non-blank string"
|
|
138
|
+
)
|
|
139
|
+
if entry.get("selective") is not True:
|
|
140
|
+
errors.append(
|
|
141
|
+
f"{prefix}: secondary_keys requires 'selective: true'"
|
|
142
|
+
)
|
|
143
|
+
# selective standalone
|
|
144
|
+
if "selective" in entry and not isinstance(entry["selective"], bool):
|
|
145
|
+
errors.append(f"{prefix}: selective must be a boolean")
|
|
146
|
+
# content
|
|
147
|
+
content = entry.get("content")
|
|
148
|
+
if content is not None:
|
|
149
|
+
if not isinstance(content, str):
|
|
150
|
+
errors.append(f"{prefix}: content must be a string")
|
|
151
|
+
else:
|
|
152
|
+
length = len(content)
|
|
153
|
+
if length < LORE_CONTENT_MIN or length > LORE_CONTENT_MAX:
|
|
154
|
+
errors.append(
|
|
155
|
+
f"{prefix}: content length {length} out of "
|
|
156
|
+
f"[{LORE_CONTENT_MIN}, {LORE_CONTENT_MAX}]"
|
|
157
|
+
)
|
|
158
|
+
# source_ref
|
|
159
|
+
source_ref = entry.get("source_ref")
|
|
160
|
+
if source_ref is not None:
|
|
161
|
+
if not isinstance(source_ref, str) or not source_ref.strip():
|
|
162
|
+
errors.append(f"{prefix}: source_ref must be a non-blank string")
|
|
163
|
+
else:
|
|
164
|
+
# Strip optional anchor (e.g. "T48n2008#般若品")
|
|
165
|
+
base = source_ref.split("#", 1)[0].strip()
|
|
166
|
+
if base not in source_ids:
|
|
167
|
+
errors.append(
|
|
168
|
+
f"{prefix}: source_ref '{source_ref}' not found in "
|
|
169
|
+
f"{slug}'s sources[].id"
|
|
170
|
+
)
|
|
171
|
+
return errors
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def validate(prebuilt: Path) -> list[str]:
|
|
175
|
+
"""Run all persona-fidelity checks over the given prebuilt tree.
|
|
176
|
+
|
|
177
|
+
Meta-skills (`kind: "meta-skill"`) carry their own protocol meta.json
|
|
178
|
+
(e.g. master-debate's `debate_protocol` block) and are not single-master
|
|
179
|
+
personas — they are skipped.
|
|
180
|
+
"""
|
|
181
|
+
errors: list[str] = []
|
|
182
|
+
for meta_path in sorted(prebuilt.glob("master-*/meta.json")):
|
|
183
|
+
slug = meta_path.parent.name.removeprefix("master-")
|
|
184
|
+
data = _load(meta_path)
|
|
185
|
+
# Skip meta-skills (debate, curriculum, etc.) — they don't carry persona fields.
|
|
186
|
+
if data.get("kind") == "meta-skill":
|
|
187
|
+
continue
|
|
188
|
+
source_ids = {
|
|
189
|
+
s.get("id") for s in data.get("sources", []) if isinstance(s, dict)
|
|
190
|
+
}
|
|
191
|
+
source_ids.discard(None)
|
|
192
|
+
errors.extend(_check_signature_phrases(slug, data))
|
|
193
|
+
errors.extend(_check_style(slug, data))
|
|
194
|
+
errors.extend(_check_lore_triggers(slug, data, source_ids))
|
|
195
|
+
return errors
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main() -> int:
|
|
199
|
+
errors = validate(PREBUILT_DIR)
|
|
200
|
+
if errors:
|
|
201
|
+
print(f"{len(errors)} persona-fidelity error(s):")
|
|
202
|
+
for e in errors:
|
|
203
|
+
print(f" ERROR: {e}")
|
|
204
|
+
return 1
|
|
205
|
+
print("persona-fidelity OK")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
sys.exit(main())
|