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,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Skill Writer — creates and updates teacher skill directories.
|
|
3
|
+
Adapted from colleague-skill's skill_writer.py for Buddhist master context.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from pypinyin import lazy_pinyin, Style
|
|
15
|
+
HAS_PYPINYIN = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
HAS_PYPINYIN = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Generated teaching/voice content is LLM output derived from external FoJin
|
|
21
|
+
# data. Before it is written into SKILL.md (which the agent later loads and
|
|
22
|
+
# follows as instructions) strip C0/C1 control chars except \n and \t: ANSI
|
|
23
|
+
# escapes / NULs serve no purpose in Buddhist-text markdown and could be used
|
|
24
|
+
# to hide content or spoof a terminal. Defense-in-depth alongside the prompt-
|
|
25
|
+
# level injection guards (prompts/sutra_analyzer.md, rag_instructions.md).
|
|
26
|
+
_CONTROL_CHARS = re.compile(
|
|
27
|
+
r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f"
|
|
28
|
+
r"\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
REQUIRED_CITATION_CLAIMS = [
|
|
32
|
+
"doctrinal_claim",
|
|
33
|
+
"practice_guidance",
|
|
34
|
+
"text_interpretation",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sanitize_generated(content: str) -> str:
|
|
39
|
+
"""Strip control characters from generated content before persisting."""
|
|
40
|
+
if not content:
|
|
41
|
+
return content
|
|
42
|
+
return _CONTROL_CHARS.sub("", content)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def derive_citation_contract(sources: list[dict]) -> dict:
|
|
46
|
+
"""Build the exact version-1 contract for a non-empty source manifest."""
|
|
47
|
+
if not isinstance(sources, list) or not sources:
|
|
48
|
+
raise ValueError("sources must be a non-empty list")
|
|
49
|
+
|
|
50
|
+
source_types: list[str] = []
|
|
51
|
+
for index, source in enumerate(sources):
|
|
52
|
+
source_type = source.get("type") if isinstance(source, dict) else None
|
|
53
|
+
if not isinstance(source_type, str) or not source_type.strip():
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"sources[{index}].type must be a non-empty string"
|
|
56
|
+
)
|
|
57
|
+
source_types.append(source_type)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"version": 1,
|
|
61
|
+
"claim_policy": "declared_sources_only",
|
|
62
|
+
"required_for": list(REQUIRED_CITATION_CLAIMS),
|
|
63
|
+
"allowed_source_types": sorted(set(source_types)),
|
|
64
|
+
"minimum_claim_coverage": 0.9,
|
|
65
|
+
"live_retrieval_allowed": True,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
SKILL_MD_TEMPLATE = """---
|
|
70
|
+
name: master-{slug}
|
|
71
|
+
description: 依据{name}({tradition}{school})的教学风格与教义体系
|
|
72
|
+
user-invocable: true
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
# {name}
|
|
76
|
+
|
|
77
|
+
{disclaimer}
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## PART A — 教义体系
|
|
82
|
+
|
|
83
|
+
{teaching_content}
|
|
84
|
+
|
|
85
|
+
## PART B — 说法风格
|
|
86
|
+
|
|
87
|
+
{voice_content}
|
|
88
|
+
|
|
89
|
+
## 运行规则
|
|
90
|
+
|
|
91
|
+
1. 收到提问后,先依据 voice.md Layer 0 硬规则检查
|
|
92
|
+
2. 依据 voice.md Layer 1-3 确定回答的风格和方式
|
|
93
|
+
3. 依据 teaching.md 检索相关教义内容
|
|
94
|
+
4. 以该法师的风格组织回答
|
|
95
|
+
5. 教义断言、修行指导、文本解释必须引用 `meta.json.sources[]` 中的 `source_id`,且来源类型须列于 `citation_contract.allowed_source_types`
|
|
96
|
+
6. live 结果只有在返回的来源类型与标识可解析到上述声明来源时才可引用;否则剥离相关断言
|
|
97
|
+
7. 遇到超出范围的问题,坦诚说明并建议查阅相关传承
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
DISCLAIMER = "本内容依据历史佛教文献生成,仅供参考学习。如需正式修行指导,请亲近善知识。所有回答均须附 persona 已声明且可核验的来源。"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def slugify(name: str) -> str:
|
|
104
|
+
"""Convert teacher name to URL-safe slug."""
|
|
105
|
+
if HAS_PYPINYIN:
|
|
106
|
+
pinyin_list = lazy_pinyin(name, style=Style.NORMAL)
|
|
107
|
+
slug = "-".join(pinyin_list)
|
|
108
|
+
else:
|
|
109
|
+
slug = name
|
|
110
|
+
slug = slug.lower().replace(" ", "-")
|
|
111
|
+
slug = "".join(c for c in slug if c.isalnum() or c == "-")
|
|
112
|
+
slug = slug.strip("-")
|
|
113
|
+
return slug
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def create_teacher(
|
|
117
|
+
base_dir: str,
|
|
118
|
+
name: str,
|
|
119
|
+
tradition: str,
|
|
120
|
+
school: str,
|
|
121
|
+
era: str,
|
|
122
|
+
languages: list,
|
|
123
|
+
teaching_content: str,
|
|
124
|
+
voice_content: str,
|
|
125
|
+
fojin_entity_id: Optional[str] = None,
|
|
126
|
+
sources: Optional[list] = None,
|
|
127
|
+
citation_contract: Optional[dict] = None,
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Create a new teacher skill directory."""
|
|
130
|
+
source_manifest = sources if sources is not None else []
|
|
131
|
+
expected_contract = derive_citation_contract(source_manifest)
|
|
132
|
+
if citation_contract is None:
|
|
133
|
+
citation_contract = expected_contract
|
|
134
|
+
elif citation_contract != expected_contract:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
"citation_contract must equal the contract derived from sources[].type"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
teaching_content = sanitize_generated(teaching_content)
|
|
140
|
+
voice_content = sanitize_generated(voice_content)
|
|
141
|
+
slug = slugify(name)
|
|
142
|
+
teacher_dir = os.path.join(base_dir, f"master-{slug}")
|
|
143
|
+
os.makedirs(teacher_dir, exist_ok=True)
|
|
144
|
+
os.makedirs(os.path.join(teacher_dir, "versions"), exist_ok=True)
|
|
145
|
+
|
|
146
|
+
with open(os.path.join(teacher_dir, "teaching.md"), "w", encoding="utf-8") as f:
|
|
147
|
+
f.write(teaching_content)
|
|
148
|
+
|
|
149
|
+
with open(os.path.join(teacher_dir, "voice.md"), "w", encoding="utf-8") as f:
|
|
150
|
+
f.write(voice_content)
|
|
151
|
+
|
|
152
|
+
skill_content = SKILL_MD_TEMPLATE.format(
|
|
153
|
+
slug=slug, name=name, tradition=tradition, school=school,
|
|
154
|
+
disclaimer=DISCLAIMER, teaching_content=teaching_content,
|
|
155
|
+
voice_content=voice_content,
|
|
156
|
+
)
|
|
157
|
+
with open(os.path.join(teacher_dir, "SKILL.md"), "w", encoding="utf-8") as f:
|
|
158
|
+
f.write(skill_content)
|
|
159
|
+
|
|
160
|
+
meta = {
|
|
161
|
+
"name": name, "slug": slug, "tradition": tradition, "school": school,
|
|
162
|
+
"era": era, "languages": languages, "fojin_entity_id": fojin_entity_id,
|
|
163
|
+
"sources": source_manifest, "citation_contract": citation_contract,
|
|
164
|
+
"version": "1.0.0",
|
|
165
|
+
"created_at": datetime.now().strftime("%Y-%m-%d"),
|
|
166
|
+
"updated_at": datetime.now().strftime("%Y-%m-%d"),
|
|
167
|
+
"disclaimer": DISCLAIMER,
|
|
168
|
+
}
|
|
169
|
+
with open(os.path.join(teacher_dir, "meta.json"), "w", encoding="utf-8") as f:
|
|
170
|
+
json.dump(meta, f, ensure_ascii=False, indent=2)
|
|
171
|
+
|
|
172
|
+
return teacher_dir
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def update_teacher(teacher_dir: str, teaching_patch: Optional[str] = None, voice_patch: Optional[str] = None) -> str:
|
|
176
|
+
"""Update an existing teacher skill with new content. Archives current version before updating."""
|
|
177
|
+
meta_path = os.path.join(teacher_dir, "meta.json")
|
|
178
|
+
with open(meta_path, "r", encoding="utf-8") as f:
|
|
179
|
+
meta = json.load(f)
|
|
180
|
+
|
|
181
|
+
version = meta.get("version", "1.0.0")
|
|
182
|
+
version_dir = os.path.join(teacher_dir, "versions", f"v{version}")
|
|
183
|
+
os.makedirs(version_dir, exist_ok=True)
|
|
184
|
+
for fname in ["SKILL.md", "teaching.md", "voice.md", "meta.json"]:
|
|
185
|
+
src = os.path.join(teacher_dir, fname)
|
|
186
|
+
if os.path.exists(src):
|
|
187
|
+
shutil.copy2(src, version_dir)
|
|
188
|
+
|
|
189
|
+
if teaching_patch:
|
|
190
|
+
with open(os.path.join(teacher_dir, "teaching.md"), "a", encoding="utf-8") as f:
|
|
191
|
+
f.write("\n\n" + sanitize_generated(teaching_patch))
|
|
192
|
+
|
|
193
|
+
if voice_patch:
|
|
194
|
+
with open(os.path.join(teacher_dir, "voice.md"), "a", encoding="utf-8") as f:
|
|
195
|
+
f.write("\n\n" + sanitize_generated(voice_patch))
|
|
196
|
+
|
|
197
|
+
parts = version.split(".")
|
|
198
|
+
parts[1] = str(int(parts[1]) + 1)
|
|
199
|
+
new_version = ".".join(parts)
|
|
200
|
+
|
|
201
|
+
meta["version"] = new_version
|
|
202
|
+
meta["updated_at"] = datetime.now().strftime("%Y-%m-%d")
|
|
203
|
+
with open(meta_path, "w", encoding="utf-8") as f:
|
|
204
|
+
json.dump(meta, f, ensure_ascii=False, indent=2)
|
|
205
|
+
|
|
206
|
+
teaching_content = open(os.path.join(teacher_dir, "teaching.md"), encoding="utf-8").read()
|
|
207
|
+
voice_content = open(os.path.join(teacher_dir, "voice.md"), encoding="utf-8").read()
|
|
208
|
+
skill_content = SKILL_MD_TEMPLATE.format(
|
|
209
|
+
slug=meta["slug"], name=meta["name"], tradition=meta["tradition"],
|
|
210
|
+
school=meta["school"], disclaimer=DISCLAIMER,
|
|
211
|
+
teaching_content=teaching_content, voice_content=voice_content,
|
|
212
|
+
)
|
|
213
|
+
with open(os.path.join(teacher_dir, "SKILL.md"), "w", encoding="utf-8") as f:
|
|
214
|
+
f.write(skill_content)
|
|
215
|
+
|
|
216
|
+
return new_version
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def list_teachers(base_dir: str) -> list:
|
|
220
|
+
"""List all teacher skills in a directory."""
|
|
221
|
+
teachers = []
|
|
222
|
+
if not os.path.exists(base_dir):
|
|
223
|
+
return teachers
|
|
224
|
+
for entry in sorted(os.listdir(base_dir)):
|
|
225
|
+
meta_path = os.path.join(base_dir, entry, "meta.json")
|
|
226
|
+
if os.path.isfile(meta_path):
|
|
227
|
+
with open(meta_path, "r", encoding="utf-8") as f:
|
|
228
|
+
meta = json.load(f)
|
|
229
|
+
teachers.append(meta)
|
|
230
|
+
return teachers
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sutra Collector — gathers Buddhist texts and metadata for a specific teacher.
|
|
3
|
+
|
|
4
|
+
Orchestrates FoJin Bridge calls to collect:
|
|
5
|
+
1. Teacher's KG entity and relations (lineage, school, texts)
|
|
6
|
+
2. Core texts associated with the teacher
|
|
7
|
+
3. Text content (selected juans)
|
|
8
|
+
4. Dictionary terms related to the teacher's tradition
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from fojin_bridge import FojinBridge, create_bridge
|
|
20
|
+
from skill_writer import derive_citation_contract
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _source_identity_from_text(text: dict) -> tuple[str, str] | None:
|
|
24
|
+
"""Normalize one FoJin text record to the source-neutral identity schema."""
|
|
25
|
+
source_type = text.get("source_type")
|
|
26
|
+
source_id = text.get("source_id")
|
|
27
|
+
if not source_type and text.get("cbeta_id"):
|
|
28
|
+
source_type = "cbeta"
|
|
29
|
+
source_id = text["cbeta_id"]
|
|
30
|
+
if not isinstance(source_type, str) or not isinstance(source_id, str):
|
|
31
|
+
return None
|
|
32
|
+
if not source_type.strip() or not source_id.strip():
|
|
33
|
+
return None
|
|
34
|
+
return source_type.strip(), source_id.strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _declared_sources_from_texts(texts: list[dict]) -> list[dict]:
|
|
38
|
+
"""Extract only canonical source identifiers returned by FoJin."""
|
|
39
|
+
sources: list[dict] = []
|
|
40
|
+
seen: set[tuple[str, str]] = set()
|
|
41
|
+
for text in texts:
|
|
42
|
+
identity = _source_identity_from_text(text)
|
|
43
|
+
if identity is None:
|
|
44
|
+
continue
|
|
45
|
+
source_type, source_id = identity
|
|
46
|
+
member = identity
|
|
47
|
+
if member in seen:
|
|
48
|
+
continue
|
|
49
|
+
seen.add(member)
|
|
50
|
+
sources.append(
|
|
51
|
+
{
|
|
52
|
+
"type": source_type,
|
|
53
|
+
"id": source_id,
|
|
54
|
+
"title": text.get("title") or text.get("title_zh") or source_id,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
return sources
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def collect_teacher_data(
|
|
61
|
+
teacher_name: str,
|
|
62
|
+
tradition: Optional[str] = None,
|
|
63
|
+
bridge: Optional[FojinBridge] = None,
|
|
64
|
+
) -> dict:
|
|
65
|
+
"""Collect all available data about a teacher from FoJin."""
|
|
66
|
+
if bridge is None:
|
|
67
|
+
bridge = create_bridge()
|
|
68
|
+
|
|
69
|
+
result = {
|
|
70
|
+
"entity": None,
|
|
71
|
+
"lineage": [],
|
|
72
|
+
"texts": [],
|
|
73
|
+
"content_samples": [],
|
|
74
|
+
"terms": [],
|
|
75
|
+
"sources": [],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Step 1: Find teacher in KG
|
|
79
|
+
kg_results = bridge.search_kg_entities(teacher_name, entity_type="person")
|
|
80
|
+
if kg_results.get("results"):
|
|
81
|
+
entity = kg_results["results"][0]
|
|
82
|
+
result["entity"] = entity
|
|
83
|
+
detail = bridge.get_kg_entity(entity["id"])
|
|
84
|
+
result["entity"] = detail
|
|
85
|
+
for rel in detail.get("relations", []):
|
|
86
|
+
if rel["predicate"] in (
|
|
87
|
+
"teacher_of", "student_of", "lineage_holder",
|
|
88
|
+
"transmitted_to", "received_from",
|
|
89
|
+
):
|
|
90
|
+
result["lineage"].append(rel)
|
|
91
|
+
|
|
92
|
+
# Step 2: Search for associated texts
|
|
93
|
+
text_results = bridge.search_texts(teacher_name, size=50)
|
|
94
|
+
if text_results.get("results"):
|
|
95
|
+
result["texts"] = text_results["results"]
|
|
96
|
+
result["sources"] = _declared_sources_from_texts(result["texts"])
|
|
97
|
+
if result["sources"]:
|
|
98
|
+
result["citation_contract"] = derive_citation_contract(result["sources"])
|
|
99
|
+
|
|
100
|
+
# Step 3: Collect content samples from top texts
|
|
101
|
+
for text in result["texts"][:5]:
|
|
102
|
+
text_id = text.get("id")
|
|
103
|
+
if not text_id:
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
content = bridge.get_text_content(text_id, juan_num=1)
|
|
107
|
+
sample = {
|
|
108
|
+
"text_id": text_id,
|
|
109
|
+
"title": text.get("title_zh", ""),
|
|
110
|
+
"content": content.get("content", "")[:3000],
|
|
111
|
+
}
|
|
112
|
+
identity = _source_identity_from_text(text)
|
|
113
|
+
if identity is not None:
|
|
114
|
+
sample["source_type"], sample["source_id"] = identity
|
|
115
|
+
result["content_samples"].append(sample)
|
|
116
|
+
except Exception:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Step 4: Collect tradition-specific terms
|
|
120
|
+
if tradition:
|
|
121
|
+
tradition_terms = {
|
|
122
|
+
"汉传": ["净土", "禅", "般若", "菩提", "念佛"],
|
|
123
|
+
"南传": ["vipassana", "satipatthana", "anicca", "dukkha", "anatta"],
|
|
124
|
+
"藏传": ["菩提道次第", "空性", "菩提心", "止观", "三主要道"],
|
|
125
|
+
}
|
|
126
|
+
for term in tradition_terms.get(tradition, []):
|
|
127
|
+
dict_results = bridge.search_dictionary(term, size=5)
|
|
128
|
+
if dict_results.get("results"):
|
|
129
|
+
result["terms"].extend(dict_results["results"])
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def collect_specific_texts(
|
|
135
|
+
cbeta_ids: list,
|
|
136
|
+
bridge: Optional[FojinBridge] = None,
|
|
137
|
+
) -> list:
|
|
138
|
+
"""Collect full content for specific texts by CBETA ID."""
|
|
139
|
+
if bridge is None:
|
|
140
|
+
bridge = create_bridge()
|
|
141
|
+
|
|
142
|
+
texts = []
|
|
143
|
+
id_map = bridge.lookup_cbeta_ids(",".join(cbeta_ids))
|
|
144
|
+
|
|
145
|
+
for cbeta_id, text_id in id_map.items():
|
|
146
|
+
if not text_id:
|
|
147
|
+
continue
|
|
148
|
+
text_meta = bridge.get_text(text_id)
|
|
149
|
+
juan_list = bridge.get_text_juans(text_id)
|
|
150
|
+
|
|
151
|
+
text_data = {
|
|
152
|
+
"cbeta_id": cbeta_id,
|
|
153
|
+
"text_id": text_id,
|
|
154
|
+
"title": text_meta.get("title_zh", ""),
|
|
155
|
+
"juans": [],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for juan in juan_list.get("juans", [])[:10]:
|
|
159
|
+
content = bridge.get_text_content(text_id, juan["juan_num"])
|
|
160
|
+
text_data["juans"].append({
|
|
161
|
+
"juan_num": juan["juan_num"],
|
|
162
|
+
"content": content.get("content", ""),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
texts.append(text_data)
|
|
166
|
+
|
|
167
|
+
return texts
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def save_collected_data(data: dict, output_path: str) -> str:
|
|
171
|
+
"""Save collected data to JSON file."""
|
|
172
|
+
parent = os.path.dirname(output_path)
|
|
173
|
+
if parent:
|
|
174
|
+
os.makedirs(parent, exist_ok=True)
|
|
175
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
176
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
177
|
+
return os.path.abspath(output_path)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def offline_smoke_data(name: str, tradition: str | None) -> dict:
|
|
181
|
+
"""Return deterministic no-network data for installed-runtime smoke tests."""
|
|
182
|
+
sources = [
|
|
183
|
+
{
|
|
184
|
+
"type": "compiled_teaching",
|
|
185
|
+
"id": "OfflineSmoke:Deterministic",
|
|
186
|
+
"title": "Deterministic offline smoke source",
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
return {
|
|
190
|
+
"entity": {
|
|
191
|
+
"name_zh": name,
|
|
192
|
+
"entity_type": "person",
|
|
193
|
+
"description": "deterministic offline smoke",
|
|
194
|
+
},
|
|
195
|
+
"tradition": tradition,
|
|
196
|
+
"lineage": [],
|
|
197
|
+
"texts": [],
|
|
198
|
+
"content_samples": [],
|
|
199
|
+
"terms": [],
|
|
200
|
+
"sources": sources,
|
|
201
|
+
"citation_contract": derive_citation_contract(sources),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main(argv: list[str] | None = None) -> int:
|
|
206
|
+
parser = argparse.ArgumentParser(
|
|
207
|
+
description="Collect declared Buddhist source data for create-master"
|
|
208
|
+
)
|
|
209
|
+
parser.add_argument("--name", required=True, help="historical teacher name")
|
|
210
|
+
parser.add_argument("--tradition", default=None, help="汉传 / 藏传 / 南传")
|
|
211
|
+
parser.add_argument("--output", required=True, help="output JSON manifest")
|
|
212
|
+
parser.add_argument(
|
|
213
|
+
"--offline-smoke",
|
|
214
|
+
action="store_true",
|
|
215
|
+
help="write deterministic source data without network access",
|
|
216
|
+
)
|
|
217
|
+
args = parser.parse_args(argv)
|
|
218
|
+
|
|
219
|
+
if args.offline_smoke:
|
|
220
|
+
data = offline_smoke_data(args.name, args.tradition)
|
|
221
|
+
else:
|
|
222
|
+
data = collect_teacher_data(args.name, args.tradition)
|
|
223
|
+
if not data.get("sources"):
|
|
224
|
+
print(
|
|
225
|
+
"ERROR: FoJin returned no canonical declared source identifiers; "
|
|
226
|
+
"curate sources[] before review",
|
|
227
|
+
file=sys.stderr,
|
|
228
|
+
)
|
|
229
|
+
return 1
|
|
230
|
+
|
|
231
|
+
destination = save_collected_data(data, args.output)
|
|
232
|
+
print(f"collected data written: {destination}")
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
sys.exit(main())
|