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.
Files changed (88) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.cursor-plugin/plugin.json +2 -2
  4. package/ETHICS.md +23 -17
  5. package/README.md +57 -14
  6. package/README_EN.md +60 -16
  7. package/SKILL.md +5 -5
  8. package/bin/cli.mjs +497 -62
  9. package/gemini-extension.json +2 -2
  10. package/hooks/run-hook.cmd +18 -5
  11. package/hooks/tests/test_run_hook.sh +114 -0
  12. package/hooks/tests/test_run_hook_cmd.sh +94 -0
  13. package/masters/.gitkeep +0 -0
  14. package/package.json +9 -3
  15. package/prebuilt/compare/SKILL.md +48 -14
  16. package/prebuilt/compare/tests/fidelity.jsonl +12 -12
  17. package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
  18. package/prebuilt/master-ajahn-chah/meta.json +8 -0
  19. package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
  20. package/prebuilt/master-atisha/SKILL.md +13 -11
  21. package/prebuilt/master-atisha/meta.json +8 -0
  22. package/prebuilt/master-atisha/references/voice.md +1 -1
  23. package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
  24. package/prebuilt/master-buddhaghosa/meta.json +8 -0
  25. package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
  26. package/prebuilt/master-fazang/SKILL.md +3 -3
  27. package/prebuilt/master-fazang/meta.json +8 -0
  28. package/prebuilt/master-huineng/SKILL.md +3 -3
  29. package/prebuilt/master-huineng/meta.json +8 -0
  30. package/prebuilt/master-kumarajiva/SKILL.md +3 -3
  31. package/prebuilt/master-kumarajiva/meta.json +8 -0
  32. package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
  33. package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
  34. package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
  35. package/prebuilt/master-milarepa/SKILL.md +13 -11
  36. package/prebuilt/master-milarepa/meta.json +8 -0
  37. package/prebuilt/master-milarepa/references/voice.md +1 -1
  38. package/prebuilt/master-nagarjuna/SKILL.md +3 -3
  39. package/prebuilt/master-nagarjuna/meta.json +8 -0
  40. package/prebuilt/master-ouyi/SKILL.md +3 -3
  41. package/prebuilt/master-ouyi/meta.json +8 -0
  42. package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
  43. package/prebuilt/master-tsongkhapa/meta.json +8 -0
  44. package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
  45. package/prebuilt/master-xuanzang/SKILL.md +3 -3
  46. package/prebuilt/master-xuanzang/meta.json +8 -0
  47. package/prebuilt/master-xuyun/SKILL.md +3 -3
  48. package/prebuilt/master-xuyun/meta.json +8 -0
  49. package/prebuilt/master-yinguang/SKILL.md +3 -3
  50. package/prebuilt/master-yinguang/meta.json +8 -0
  51. package/prebuilt/master-zhiyi/SKILL.md +3 -3
  52. package/prebuilt/master-zhiyi/meta.json +8 -0
  53. package/prompts/correction_handler.md +104 -0
  54. package/prompts/doctrine_reviewer.md +61 -0
  55. package/prompts/intake.md +62 -0
  56. package/prompts/merger.md +62 -0
  57. package/prompts/rag_instructions.md +54 -0
  58. package/prompts/sutra_analyzer.md +83 -0
  59. package/prompts/teaching_builder.md +41 -0
  60. package/prompts/voice_analyzer.md +92 -0
  61. package/prompts/voice_builder.md +48 -0
  62. package/prompts/voice_reviewer.md +66 -0
  63. package/references/README.md +12 -0
  64. package/references/ethics-runtime.md +112 -0
  65. package/references/fojin-api.md +223 -0
  66. package/references/source-conventions.md +129 -0
  67. package/references/teaching-modes.md +84 -0
  68. package/references/traditions.md +72 -0
  69. package/references/workflow-details.md +361 -0
  70. package/requirements.txt +6 -0
  71. package/scripts/select-fidelity-smoke.py +78 -0
  72. package/scripts/test-fidelity.py +40 -10
  73. package/scripts/tests/test_select_fidelity_smoke.py +142 -0
  74. package/scripts/tests/test_validate_citation_contract.py +408 -0
  75. package/scripts/tests/test_validate_fidelity.py +59 -0
  76. package/scripts/tests/test_validate_workflow.py +265 -0
  77. package/scripts/validate-citation-contract.py +193 -0
  78. package/scripts/validate-fidelity.py +20 -0
  79. package/scripts/verify_citations.py +8 -1
  80. package/skill-catalog.json +147 -0
  81. package/tools/cross_reference.py +365 -0
  82. package/tools/fojin_bridge.py +146 -0
  83. package/tools/master_builder.py +341 -0
  84. package/tools/rag_query.py +336 -0
  85. package/tools/skill_writer.py +230 -0
  86. package/tools/sutra_collector.py +237 -0
  87. package/tools/verify_sources.py +512 -0
  88. package/tools/version_manager.py +88 -0
@@ -0,0 +1,147 @@
1
+ {
2
+ "version": 1,
3
+ "skills": [
4
+ {
5
+ "name": "master-nagarjuna",
6
+ "kind": "persona",
7
+ "source": "prebuilt/master-nagarjuna",
8
+ "install_dir": "master-nagarjuna",
9
+ "aliases": ["nagarjuna", "master-nagarjuna"]
10
+ },
11
+ {
12
+ "name": "master-xuanzang",
13
+ "kind": "persona",
14
+ "source": "prebuilt/master-xuanzang",
15
+ "install_dir": "master-xuanzang",
16
+ "aliases": ["xuanzang", "master-xuanzang"]
17
+ },
18
+ {
19
+ "name": "master-kumarajiva",
20
+ "kind": "persona",
21
+ "source": "prebuilt/master-kumarajiva",
22
+ "install_dir": "master-kumarajiva",
23
+ "aliases": ["kumarajiva", "master-kumarajiva"]
24
+ },
25
+ {
26
+ "name": "master-huineng",
27
+ "kind": "persona",
28
+ "source": "prebuilt/master-huineng",
29
+ "install_dir": "master-huineng",
30
+ "aliases": ["huineng", "master-huineng"]
31
+ },
32
+ {
33
+ "name": "master-zhiyi",
34
+ "kind": "persona",
35
+ "source": "prebuilt/master-zhiyi",
36
+ "install_dir": "master-zhiyi",
37
+ "aliases": ["zhiyi", "master-zhiyi"]
38
+ },
39
+ {
40
+ "name": "master-fazang",
41
+ "kind": "persona",
42
+ "source": "prebuilt/master-fazang",
43
+ "install_dir": "master-fazang",
44
+ "aliases": ["fazang", "master-fazang"]
45
+ },
46
+ {
47
+ "name": "master-yinguang",
48
+ "kind": "persona",
49
+ "source": "prebuilt/master-yinguang",
50
+ "install_dir": "master-yinguang",
51
+ "aliases": ["yinguang", "master-yinguang"]
52
+ },
53
+ {
54
+ "name": "master-ouyi",
55
+ "kind": "persona",
56
+ "source": "prebuilt/master-ouyi",
57
+ "install_dir": "master-ouyi",
58
+ "aliases": ["ouyi", "master-ouyi"]
59
+ },
60
+ {
61
+ "name": "master-xuyun",
62
+ "kind": "persona",
63
+ "source": "prebuilt/master-xuyun",
64
+ "install_dir": "master-xuyun",
65
+ "aliases": ["xuyun", "master-xuyun"]
66
+ },
67
+ {
68
+ "name": "master-atisha",
69
+ "kind": "persona",
70
+ "source": "prebuilt/master-atisha",
71
+ "install_dir": "master-atisha",
72
+ "aliases": ["atisha", "master-atisha"]
73
+ },
74
+ {
75
+ "name": "master-tsongkhapa",
76
+ "kind": "persona",
77
+ "source": "prebuilt/master-tsongkhapa",
78
+ "install_dir": "master-tsongkhapa",
79
+ "aliases": ["tsongkhapa", "master-tsongkhapa"]
80
+ },
81
+ {
82
+ "name": "master-milarepa",
83
+ "kind": "persona",
84
+ "source": "prebuilt/master-milarepa",
85
+ "install_dir": "master-milarepa",
86
+ "aliases": ["milarepa", "master-milarepa"]
87
+ },
88
+ {
89
+ "name": "master-buddhaghosa",
90
+ "kind": "persona",
91
+ "source": "prebuilt/master-buddhaghosa",
92
+ "install_dir": "master-buddhaghosa",
93
+ "aliases": ["buddhaghosa", "master-buddhaghosa"]
94
+ },
95
+ {
96
+ "name": "master-mahasi-sayadaw",
97
+ "kind": "persona",
98
+ "source": "prebuilt/master-mahasi-sayadaw",
99
+ "install_dir": "master-mahasi-sayadaw",
100
+ "aliases": ["mahasi-sayadaw", "master-mahasi-sayadaw"]
101
+ },
102
+ {
103
+ "name": "master-ajahn-chah",
104
+ "kind": "persona",
105
+ "source": "prebuilt/master-ajahn-chah",
106
+ "install_dir": "master-ajahn-chah",
107
+ "aliases": ["ajahn-chah", "master-ajahn-chah"]
108
+ },
109
+ {
110
+ "name": "compare-masters",
111
+ "kind": "teaching-mode",
112
+ "source": "prebuilt/compare",
113
+ "install_dir": "compare-masters",
114
+ "aliases": ["compare-masters"]
115
+ },
116
+ {
117
+ "name": "master-debate",
118
+ "kind": "teaching-mode",
119
+ "source": "prebuilt/master-debate",
120
+ "install_dir": "master-debate",
121
+ "aliases": ["master-debate"]
122
+ },
123
+ {
124
+ "name": "master-curriculum",
125
+ "kind": "teaching-mode",
126
+ "source": "prebuilt/master-curriculum",
127
+ "install_dir": "master-curriculum",
128
+ "aliases": ["master-curriculum"]
129
+ },
130
+ {
131
+ "name": "create-master",
132
+ "kind": "generator",
133
+ "source": ".",
134
+ "install_dir": "create-master",
135
+ "aliases": ["create-master"],
136
+ "bundle_paths": [
137
+ "SKILL.md",
138
+ "tools",
139
+ "prompts",
140
+ "references",
141
+ "requirements.txt",
142
+ "ETHICS.md",
143
+ "masters"
144
+ ]
145
+ }
146
+ ]
147
+ }
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Cross-Reference — find connections between teachers using FoJin KG.
4
+
5
+ Usage:
6
+ python3 tools/cross_reference.py lineage "印光" "蕅益"
7
+ python3 tools/cross_reference.py concept "空性" --teachers xuanzang,kumarajiva
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ # Allow running from project root or tools/
18
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
19
+ sys.path.insert(0, str(PROJECT_ROOT / "tools"))
20
+
21
+ from fojin_bridge import FojinBridge, create_bridge
22
+
23
+
24
+ # ── Teacher registry ────────────────────────────────────────────
25
+
26
+ PREBUILT_DIR = PROJECT_ROOT / "prebuilt"
27
+
28
+
29
+ def load_teacher_meta(slug: str) -> dict:
30
+ """Load a teacher's meta.json by slug."""
31
+ meta_path = PREBUILT_DIR / slug / "meta.json"
32
+ if not meta_path.exists():
33
+ raise FileNotFoundError(f"Teacher not found: {slug}")
34
+ with open(meta_path, "r", encoding="utf-8") as f:
35
+ return json.load(f)
36
+
37
+
38
+ def list_teachers() -> list[str]:
39
+ """Return all available teacher slugs."""
40
+ return sorted(
41
+ d.name for d in PREBUILT_DIR.iterdir()
42
+ if d.is_dir() and (d / "meta.json").exists()
43
+ )
44
+
45
+
46
+ def find_teacher_by_name(name: str) -> Optional[dict]:
47
+ """Find a teacher by Chinese name, English name, or slug."""
48
+ for slug in list_teachers():
49
+ meta = load_teacher_meta(slug)
50
+ if name in (
51
+ meta.get("name"),
52
+ meta.get("name_en"),
53
+ meta.get("name_sa"),
54
+ meta.get("name_bo"),
55
+ meta.get("slug"),
56
+ slug,
57
+ ):
58
+ return meta
59
+ return None
60
+
61
+
62
+ # ── Lineage subcommand ──────────────────────────────────────────
63
+
64
+ def cmd_lineage(bridge: FojinBridge, person_a: str, person_b: str) -> str:
65
+ """
66
+ Search the KG for relationship paths between two people.
67
+
68
+ Returns formatted text describing the lineage connection.
69
+ """
70
+ lines: list[str] = []
71
+ lines.append(f"## 师承关系查询: {person_a} <-> {person_b}")
72
+ lines.append("")
73
+
74
+ # Search for both entities in the KG
75
+ result_a = bridge.search_kg_entities(person_a, entity_type="person", limit=5)
76
+ result_b = bridge.search_kg_entities(person_b, entity_type="person", limit=5)
77
+
78
+ entities_a = result_a.get("data", result_a.get("results", []))
79
+ entities_b = result_b.get("data", result_b.get("results", []))
80
+
81
+ if not entities_a:
82
+ lines.append(f"- 未在知识图谱中找到: {person_a}")
83
+ if not entities_b:
84
+ lines.append(f"- 未在知识图谱中找到: {person_b}")
85
+
86
+ if not entities_a or not entities_b:
87
+ lines.append("")
88
+ lines.append("### 建议")
89
+ lines.append("- 尝试使用不同的名称(法号、别名等)")
90
+ lines.append("- 查看 prebuilt/ 目录下已有的法师配置")
91
+ return "\n".join(lines)
92
+
93
+ entity_a = entities_a[0]
94
+ entity_b = entities_b[0]
95
+ id_a = entity_a.get("id")
96
+ id_b = entity_b.get("id")
97
+
98
+ lines.append(f"### {person_a}")
99
+ lines.append(f"- KG实体: {entity_a.get('name', person_a)} (ID: {id_a})")
100
+ lines.append("")
101
+
102
+ lines.append(f"### {person_b}")
103
+ lines.append(f"- KG实体: {entity_b.get('name', person_b)} (ID: {id_b})")
104
+ lines.append("")
105
+
106
+ # Fetch relationship graphs for both, looking for teacher-student predicates
107
+ lines.append("### 关系图谱")
108
+ lines.append("")
109
+
110
+ graph_a = bridge.get_kg_graph(
111
+ id_a, depth=3, max_nodes=100,
112
+ predicates="teacher_of,student_of,disciple_of,master_of"
113
+ )
114
+
115
+ nodes = {n["id"]: n for n in graph_a.get("nodes", [])}
116
+ edges = graph_a.get("edges", graph_a.get("links", []))
117
+
118
+ # Check if person_b appears in person_a's graph
119
+ found_connection = False
120
+ for node in nodes.values():
121
+ node_name = node.get("name", "")
122
+ if person_b in node_name or node.get("id") == id_b:
123
+ found_connection = True
124
+ break
125
+
126
+ if found_connection:
127
+ lines.append(f"在 {person_a} 的关系图谱中找到 {person_b}:")
128
+ lines.append("")
129
+ for edge in edges:
130
+ src = nodes.get(edge.get("source"), {}).get("name", str(edge.get("source")))
131
+ tgt = nodes.get(edge.get("target"), {}).get("name", str(edge.get("target")))
132
+ pred = edge.get("predicate", edge.get("label", "related_to"))
133
+ lines.append(f" {src} --[{pred}]--> {tgt}")
134
+ else:
135
+ lines.append(f"在3层关系内未找到 {person_a} 与 {person_b} 的直接师承路径。")
136
+ lines.append("")
137
+
138
+ # Show each person's immediate relations for context
139
+ lines.append(f"#### {person_a} 的师承关系:")
140
+ for edge in edges[:10]:
141
+ src = nodes.get(edge.get("source"), {}).get("name", str(edge.get("source")))
142
+ tgt = nodes.get(edge.get("target"), {}).get("name", str(edge.get("target")))
143
+ pred = edge.get("predicate", edge.get("label", "related_to"))
144
+ lines.append(f" {src} --[{pred}]--> {tgt}")
145
+
146
+ graph_b = bridge.get_kg_graph(
147
+ id_b, depth=2, max_nodes=50,
148
+ predicates="teacher_of,student_of,disciple_of,master_of"
149
+ )
150
+ nodes_b = {n["id"]: n for n in graph_b.get("nodes", [])}
151
+ edges_b = graph_b.get("edges", graph_b.get("links", []))
152
+
153
+ lines.append("")
154
+ lines.append(f"#### {person_b} 的师承关系:")
155
+ for edge in edges_b[:10]:
156
+ src = nodes_b.get(edge.get("source"), {}).get("name", str(edge.get("source")))
157
+ tgt = nodes_b.get(edge.get("target"), {}).get("name", str(edge.get("target")))
158
+ pred = edge.get("predicate", edge.get("label", "related_to"))
159
+ lines.append(f" {src} --[{pred}]--> {tgt}")
160
+
161
+ # Check if both are prebuilt teachers and note shared traditions
162
+ lines.append("")
163
+ lines.append("### 传承交集分析")
164
+ meta_a = find_teacher_by_name(person_a)
165
+ meta_b = find_teacher_by_name(person_b)
166
+ if meta_a and meta_b:
167
+ scope_a = meta_a.get("search_scope", {})
168
+ scope_b = meta_b.get("search_scope", {})
169
+ shared_traditions = set(scope_a.get("traditions", [])) & set(scope_b.get("traditions", []))
170
+ shared_keywords = set(scope_a.get("keywords", [])) & set(scope_b.get("keywords", []))
171
+
172
+ if shared_traditions:
173
+ lines.append(f"- 共同传承: {', '.join(shared_traditions)}")
174
+ if shared_keywords:
175
+ lines.append(f"- 共同关键词: {', '.join(shared_keywords)}")
176
+ if not shared_traditions and not shared_keywords:
177
+ lines.append("- 两位法师属于不同传承,无直接交集")
178
+ else:
179
+ lines.append("- 部分法师不在预置列表中,无法进行本地交集分析")
180
+
181
+ return "\n".join(lines)
182
+
183
+
184
+ # ── Concept subcommand ──────────────────────────────────────────
185
+
186
+ def cmd_concept(bridge: FojinBridge, concept: str, teacher_slugs: list[str]) -> str:
187
+ """
188
+ Search the same concept across multiple teachers' primary sources.
189
+
190
+ Returns formatted text comparing how different teachers discuss the concept.
191
+ """
192
+ lines: list[str] = []
193
+ lines.append(f"## 跨传承概念对比: {concept}")
194
+ lines.append("")
195
+
196
+ # Dictionary lookup first
197
+ lines.append("### 辞典释义")
198
+ try:
199
+ dict_result = bridge.search_dictionary_grouped(concept)
200
+ entries = dict_result.get("data", dict_result.get("results", []))
201
+ if entries:
202
+ for group in entries[:3]:
203
+ source = group.get("source", "unknown")
204
+ items = group.get("entries", group.get("items", []))
205
+ if items:
206
+ entry = items[0]
207
+ term = entry.get("term", entry.get("headword", concept))
208
+ definition = entry.get("definition", entry.get("content", ""))
209
+ # Truncate long definitions
210
+ if len(definition) > 200:
211
+ definition = definition[:200] + "..."
212
+ lines.append(f"- **{source}** [{term}]: {definition}")
213
+ else:
214
+ lines.append(f"- 未找到「{concept}」的辞典条目")
215
+ except Exception as e:
216
+ lines.append(f"- 辞典查询失败: {e}")
217
+
218
+ lines.append("")
219
+
220
+ # For each teacher, search their primary sources
221
+ for slug in teacher_slugs:
222
+ try:
223
+ meta = load_teacher_meta(slug)
224
+ except FileNotFoundError:
225
+ lines.append(f"### {slug} (未找到)")
226
+ lines.append(f"- 法师 '{slug}' 不在预置列表中")
227
+ lines.append("")
228
+ continue
229
+
230
+ name = meta.get("name", slug)
231
+ scope = meta.get("search_scope", {})
232
+ traditions = scope.get("traditions", [])
233
+
234
+ lines.append(f"### {name} ({', '.join(traditions)})")
235
+
236
+ # Build source filter from primary CBETA/SuttaCentral IDs
237
+ primary_ids = scope.get("primary_cbeta_ids", []) + scope.get("primary_suttacentral_ids", [])
238
+ source_filter = ",".join(primary_ids) if primary_ids else None
239
+
240
+ # Search in teacher's primary texts
241
+ try:
242
+ results = bridge.search_content(concept, sources=source_filter, size=5)
243
+ hits = results.get("data", results.get("results", []))
244
+ if hits:
245
+ for hit in hits[:3]:
246
+ title = hit.get("title", hit.get("text_title", ""))
247
+ highlight = hit.get("highlight", hit.get("snippet", ""))
248
+ if isinstance(highlight, dict):
249
+ highlight = highlight.get("content", [""])[0]
250
+ if isinstance(highlight, list):
251
+ highlight = highlight[0] if highlight else ""
252
+ # Truncate
253
+ if len(highlight) > 150:
254
+ highlight = highlight[:150] + "..."
255
+ lines.append(f"- 《{title}》: {highlight}")
256
+ else:
257
+ lines.append(f"- 在主要典籍中未搜到「{concept}」的直接引用")
258
+ except Exception as e:
259
+ lines.append(f"- 搜索失败: {e}")
260
+
261
+ # Also search dictionary with teacher-specific sources
262
+ dict_sources = scope.get("dictionary_sources", [])
263
+ if dict_sources:
264
+ try:
265
+ for ds in dict_sources[:2]:
266
+ dict_res = bridge.search_dictionary(concept, source=ds, size=1)
267
+ entries = dict_res.get("data", dict_res.get("results", []))
268
+ if entries:
269
+ entry = entries[0]
270
+ definition = entry.get("definition", entry.get("content", ""))
271
+ if len(definition) > 100:
272
+ definition = definition[:100] + "..."
273
+ lines.append(f"- [{ds}辞典]: {definition}")
274
+ except Exception:
275
+ pass # Dictionary lookup is best-effort
276
+
277
+ lines.append("")
278
+
279
+ # Cross-tradition comparison summary
280
+ lines.append("### 传承视角对比")
281
+ tradition_map: dict[str, list[str]] = {}
282
+ for slug in teacher_slugs:
283
+ try:
284
+ meta = load_teacher_meta(slug)
285
+ scope = meta.get("search_scope", {})
286
+ for t in scope.get("traditions", []):
287
+ tradition_map.setdefault(t, []).append(meta.get("name", slug))
288
+ except FileNotFoundError:
289
+ pass
290
+
291
+ for tradition, teachers in tradition_map.items():
292
+ lines.append(f"- **{tradition}**: {', '.join(teachers)}")
293
+
294
+ if len(tradition_map) > 1:
295
+ lines.append("")
296
+ lines.append(
297
+ f"共涉及 {len(tradition_map)} 个传承,"
298
+ f"建议对比各传承对「{concept}」的不同诠释角度。"
299
+ )
300
+
301
+ return "\n".join(lines)
302
+
303
+
304
+ # ── CLI ─────────────────────────────────────────────────────────
305
+
306
+ def main():
307
+ parser = argparse.ArgumentParser(
308
+ description="Cross-reference Buddhist masters and concepts via FoJin KG",
309
+ formatter_class=argparse.RawDescriptionHelpFormatter,
310
+ epilog="""Examples:
311
+ %(prog)s lineage "印光" "蕅益"
312
+ %(prog)s concept "空性" --teachers xuanzang,kumarajiva
313
+ %(prog)s concept "止观" --teachers zhiyi,ouyi,xuyun
314
+ %(prog)s list
315
+ """,
316
+ )
317
+ sub = parser.add_subparsers(dest="command")
318
+
319
+ # lineage
320
+ p_lineage = sub.add_parser("lineage", help="查询两人师承关系")
321
+ p_lineage.add_argument("person_a", help="第一位人物名称")
322
+ p_lineage.add_argument("person_b", help="第二位人物名称")
323
+
324
+ # concept
325
+ p_concept = sub.add_parser("concept", help="跨传承概念对比")
326
+ p_concept.add_argument("concept", help="要比较的概念")
327
+ p_concept.add_argument(
328
+ "--teachers", "-t", required=True,
329
+ help="法师slug列表,逗号分隔 (如: xuanzang,kumarajiva)"
330
+ )
331
+
332
+ # list
333
+ sub.add_parser("list", help="列出所有可用法师")
334
+
335
+ args = parser.parse_args()
336
+
337
+ if args.command == "list":
338
+ print("Available teachers:")
339
+ for slug in list_teachers():
340
+ meta = load_teacher_meta(slug)
341
+ scope = meta.get("search_scope", {})
342
+ traditions = ", ".join(scope.get("traditions", []))
343
+ print(f" {slug:16s} {meta['name']} ({traditions})")
344
+ return
345
+
346
+ if args.command is None:
347
+ parser.print_help()
348
+ return
349
+
350
+ bridge = create_bridge()
351
+
352
+ if args.command == "lineage":
353
+ output = cmd_lineage(bridge, args.person_a, args.person_b)
354
+ elif args.command == "concept":
355
+ teacher_slugs = [s.strip() for s in args.teachers.split(",")]
356
+ output = cmd_concept(bridge, args.concept, teacher_slugs)
357
+ else:
358
+ parser.print_help()
359
+ return
360
+
361
+ print(output)
362
+
363
+
364
+ if __name__ == "__main__":
365
+ main()
@@ -0,0 +1,146 @@
1
+ """
2
+ FoJin Data Bridge — connects Master-skill to FoJin's Buddhist text platform.
3
+
4
+ Two modes:
5
+ - API mode (default): calls fojin.app REST API, works for any user
6
+ - Local mode: direct database access, for FoJin developers only
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ from typing import Optional
13
+
14
+ import requests
15
+
16
+
17
+ class FojinUnavailableError(Exception):
18
+ """Raised when FoJin API is unreachable. Callers should handle gracefully."""
19
+ pass
20
+
21
+
22
+ class FojinBridge:
23
+ """Bridge to FoJin Buddhist text platform."""
24
+
25
+ def __init__(self, mode: str = "api", base_url: str = "https://fojin.app"):
26
+ self.mode = mode
27
+ self.base_url = base_url.rstrip("/")
28
+ self.session = requests.Session()
29
+ self.session.headers.update({"Accept": "application/json"})
30
+
31
+ # ── Search ──────────────────────────────────────────────
32
+
33
+ def search_texts(self, query: str, sources: Optional[str] = None, lang: Optional[str] = None, page: int = 1, size: int = 20) -> dict:
34
+ """Search Buddhist texts by keyword."""
35
+ params = {"q": query, "page": page, "size": size}
36
+ if sources:
37
+ params["sources"] = sources
38
+ if lang:
39
+ params["lang"] = lang
40
+ return self._get("/api/search", params)
41
+
42
+ def search_content(self, query: str, sources: Optional[str] = None, page: int = 1, size: int = 20) -> dict:
43
+ """Full-text content search with highlighting."""
44
+ params = {"q": query, "page": page, "size": size}
45
+ if sources:
46
+ params["sources"] = sources
47
+ return self._get("/api/search/content", params)
48
+
49
+ def semantic_search(self, query: str, top_k: int = 10) -> dict:
50
+ """Vector similarity search using pgvector embeddings."""
51
+ params = {"q": query, "size": top_k}
52
+ return self._get("/api/search/semantic", params)
53
+
54
+ # ── Texts ───────────────────────────────────────────────
55
+
56
+ def get_text(self, text_id: int) -> dict:
57
+ """Get text metadata by ID."""
58
+ return self._get(f"/api/texts/{text_id}")
59
+
60
+ def get_text_content(self, text_id: int, juan_num: int, lang: Optional[str] = None) -> dict:
61
+ """Get full content of a specific juan (scroll/fascicle)."""
62
+ params = {}
63
+ if lang:
64
+ params["lang"] = lang
65
+ return self._get(f"/api/texts/{text_id}/juans/{juan_num}", params)
66
+
67
+ def get_text_juans(self, text_id: int) -> dict:
68
+ """List all juans for a text."""
69
+ return self._get(f"/api/texts/{text_id}/juans")
70
+
71
+ def lookup_cbeta_ids(self, ids: str) -> dict:
72
+ """Batch lookup CBETA IDs to internal IDs."""
73
+ return self._get("/api/texts/lookup-cbeta", {"ids": ids})
74
+
75
+ def get_similar_passages(self, text_id: int, juan_num: int) -> dict:
76
+ """Find similar passages using pgvector similarity."""
77
+ return self._get(f"/api/texts/{text_id}/juans/{juan_num}/similar")
78
+
79
+ # ── Knowledge Graph ─────────────────────────────────────
80
+
81
+ def search_kg_entities(self, query: str, entity_type: Optional[str] = None, limit: int = 20) -> dict:
82
+ """Search knowledge graph entities."""
83
+ params = {"q": query, "limit": limit}
84
+ if entity_type:
85
+ params["entity_type"] = entity_type
86
+ return self._get("/api/kg/entities", params)
87
+
88
+ def get_kg_entity(self, entity_id: int) -> dict:
89
+ """Get detailed entity info with relations."""
90
+ return self._get(f"/api/kg/entities/{entity_id}")
91
+
92
+ def get_kg_graph(self, entity_id: int, depth: int = 2, max_nodes: int = 150, predicates: Optional[str] = None) -> dict:
93
+ """Get entity's relationship graph."""
94
+ params = {"depth": depth, "max_nodes": max_nodes}
95
+ if predicates:
96
+ params["predicates"] = predicates
97
+ return self._get(f"/api/kg/entities/{entity_id}/graph", params)
98
+
99
+ # ── Dictionary ──────────────────────────────────────────
100
+
101
+ def search_dictionary(self, query: str, lang: Optional[str] = None, source: Optional[str] = None, page: int = 1, size: int = 20) -> dict:
102
+ """Search Buddhist dictionaries."""
103
+ params = {"q": query, "page": page, "size": size}
104
+ if lang:
105
+ params["lang"] = lang
106
+ if source:
107
+ params["source"] = source
108
+ return self._get("/api/dictionary/search", params)
109
+
110
+ def search_dictionary_grouped(self, query: str) -> dict:
111
+ """Search dictionaries, results grouped by source."""
112
+ return self._get("/api/dictionary/search/grouped", {"q": query})
113
+
114
+ # ── Helpers ──────────────────────────────────────────────
115
+
116
+ def _get(self, path: str, params: Optional[dict] = None) -> dict:
117
+ """Make GET request to FoJin API.
118
+
119
+ Raises:
120
+ FojinUnavailableError: When FoJin is unreachable (connection/timeout)
121
+ requests.HTTPError: On 4xx/5xx responses
122
+ """
123
+ url = f"{self.base_url}{path}"
124
+ try:
125
+ resp = self.session.get(url, params=params, timeout=30)
126
+ resp.raise_for_status()
127
+ return resp.json()
128
+ except requests.ConnectionError as e:
129
+ raise FojinUnavailableError(f"FoJin API unreachable: {e}") from e
130
+ except requests.Timeout as e:
131
+ raise FojinUnavailableError(f"FoJin API timeout: {e}") from e
132
+
133
+ def test_connection(self) -> bool:
134
+ """Test if FoJin API is reachable."""
135
+ try:
136
+ self._get("/api/stats")
137
+ return True
138
+ except Exception:
139
+ return False
140
+
141
+
142
+ def create_bridge() -> FojinBridge:
143
+ """Create a FojinBridge from environment variables."""
144
+ mode = os.environ.get("FOJIN_MODE", "api")
145
+ url = os.environ.get("FOJIN_URL", "https://fojin.app")
146
+ return FojinBridge(mode=mode, base_url=url)