master-skill 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +2 -2
- package/ETHICS.md +23 -17
- package/README.md +57 -14
- package/README_EN.md +60 -16
- package/SKILL.md +5 -5
- package/bin/cli.mjs +497 -62
- package/gemini-extension.json +2 -2
- package/hooks/run-hook.cmd +18 -5
- package/hooks/tests/test_run_hook.sh +114 -0
- package/hooks/tests/test_run_hook_cmd.sh +94 -0
- package/masters/.gitkeep +0 -0
- package/package.json +9 -3
- package/prebuilt/compare/SKILL.md +48 -14
- package/prebuilt/compare/tests/fidelity.jsonl +12 -12
- package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
- package/prebuilt/master-ajahn-chah/meta.json +8 -0
- package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
- package/prebuilt/master-atisha/SKILL.md +13 -11
- package/prebuilt/master-atisha/meta.json +8 -0
- package/prebuilt/master-atisha/references/voice.md +1 -1
- package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
- package/prebuilt/master-buddhaghosa/meta.json +8 -0
- package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
- package/prebuilt/master-fazang/SKILL.md +3 -3
- package/prebuilt/master-fazang/meta.json +8 -0
- package/prebuilt/master-huineng/SKILL.md +3 -3
- package/prebuilt/master-huineng/meta.json +8 -0
- package/prebuilt/master-kumarajiva/SKILL.md +3 -3
- package/prebuilt/master-kumarajiva/meta.json +8 -0
- package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
- package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
- package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
- package/prebuilt/master-milarepa/SKILL.md +13 -11
- package/prebuilt/master-milarepa/meta.json +8 -0
- package/prebuilt/master-milarepa/references/voice.md +1 -1
- package/prebuilt/master-nagarjuna/SKILL.md +3 -3
- package/prebuilt/master-nagarjuna/meta.json +8 -0
- package/prebuilt/master-ouyi/SKILL.md +3 -3
- package/prebuilt/master-ouyi/meta.json +8 -0
- package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
- package/prebuilt/master-tsongkhapa/meta.json +8 -0
- package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
- package/prebuilt/master-xuanzang/SKILL.md +3 -3
- package/prebuilt/master-xuanzang/meta.json +8 -0
- package/prebuilt/master-xuyun/SKILL.md +3 -3
- package/prebuilt/master-xuyun/meta.json +8 -0
- package/prebuilt/master-yinguang/SKILL.md +3 -3
- package/prebuilt/master-yinguang/meta.json +8 -0
- package/prebuilt/master-zhiyi/SKILL.md +3 -3
- package/prebuilt/master-zhiyi/meta.json +8 -0
- package/prompts/correction_handler.md +104 -0
- package/prompts/doctrine_reviewer.md +61 -0
- package/prompts/intake.md +62 -0
- package/prompts/merger.md +62 -0
- package/prompts/rag_instructions.md +54 -0
- package/prompts/sutra_analyzer.md +83 -0
- package/prompts/teaching_builder.md +41 -0
- package/prompts/voice_analyzer.md +92 -0
- package/prompts/voice_builder.md +48 -0
- package/prompts/voice_reviewer.md +66 -0
- package/references/README.md +12 -0
- package/references/ethics-runtime.md +112 -0
- package/references/fojin-api.md +223 -0
- package/references/source-conventions.md +129 -0
- package/references/teaching-modes.md +84 -0
- package/references/traditions.md +72 -0
- package/references/workflow-details.md +361 -0
- package/requirements.txt +6 -0
- package/scripts/select-fidelity-smoke.py +78 -0
- package/scripts/test-fidelity.py +40 -10
- package/scripts/tests/test_select_fidelity_smoke.py +142 -0
- package/scripts/tests/test_validate_citation_contract.py +408 -0
- package/scripts/tests/test_validate_fidelity.py +59 -0
- package/scripts/tests/test_validate_workflow.py +265 -0
- package/scripts/validate-citation-contract.py +193 -0
- package/scripts/validate-fidelity.py +20 -0
- package/scripts/verify_citations.py +8 -1
- package/skill-catalog.json +147 -0
- package/tools/cross_reference.py +365 -0
- package/tools/fojin_bridge.py +146 -0
- package/tools/master_builder.py +341 -0
- package/tools/rag_query.py +336 -0
- package/tools/skill_writer.py +230 -0
- package/tools/sutra_collector.py +237 -0
- package/tools/verify_sources.py +512 -0
- package/tools/version_manager.py +88 -0
|
@@ -0,0 +1,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)
|