master-skill 0.10.1 → 0.12.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/GEMINI.md +1 -1
- package/README.md +84 -336
- package/README_EN.md +108 -321
- package/bin/cli.mjs +249 -9
- package/gemini-extension.json +1 -1
- package/hooks/session-start +68 -74
- package/hooks/session_start.py +152 -0
- package/package.json +6 -2
- package/prebuilt/{compare → compare-masters}/SKILL.md +21 -2
- package/prebuilt/master-ajahn-chah/meta.json +6 -0
- package/prebuilt/master-ajahn-chah/tests/fidelity.jsonl +6 -6
- package/prebuilt/master-atisha/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-curriculum/SKILL.md +1 -1
- package/prebuilt/master-curriculum/references/tiantai.md +1 -1
- package/prebuilt/master-debate/SKILL.md +15 -3
- package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
- package/prebuilt/master-help/SKILL.md +94 -0
- package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
- package/prebuilt/master-huineng/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-kumarajiva/meta.json +14 -3
- package/prebuilt/master-kumarajiva/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-mahasi-sayadaw/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-milarepa/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-nagarjuna/meta.json +19 -4
- package/prebuilt/master-nagarjuna/tests/fidelity.jsonl +6 -6
- package/prebuilt/master-ouyi/meta.json +5 -0
- package/prebuilt/master-ouyi/references/teaching.md +3 -3
- package/prebuilt/master-ouyi/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-tsongkhapa/meta.json +32 -5
- package/prebuilt/master-tsongkhapa/tests/fidelity.jsonl +2 -2
- package/prebuilt/master-xuanzang/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-xuyun/tests/fidelity.jsonl +6 -6
- package/prebuilt/master-zhiyi/meta.json +2 -2
- package/prebuilt/master-zhiyi/tests/fidelity.jsonl +2 -2
- package/references/teaching-modes.md +8 -1
- package/routing.json +209 -0
- package/scripts/check-audit-ignores.py +105 -0
- package/scripts/check-eval-sdk-surface.py +142 -0
- package/scripts/check-gate-liveness.py +421 -0
- package/scripts/reaudit-report.py +163 -0
- package/scripts/regrade-report.py +157 -0
- package/scripts/smoke-eval-sdk.py +174 -0
- package/scripts/test-fidelity.py +992 -89
- package/scripts/validate-citation-references.py +150 -0
- package/scripts/validate-citation-templates.py +176 -0
- package/scripts/validate-fidelity.py +6 -1
- package/scripts/validate-fixture-terms.py +127 -0
- package/scripts/validate-routing.py +254 -0
- package/scripts/validate.py +63 -36
- package/scripts/verify-adjudication.py +316 -0
- package/scripts/verify_citations.py +739 -39
- package/skill-catalog.json +83 -20
- package/tools/cross_reference.py +44 -10
- package/tools/fojin-known-absent.json +14 -0
- package/tools/fojin_bridge.py +138 -8
- package/tools/rag_query.py +45 -2
- package/tools/skill_writer.py +50 -7
- package/tools/verify_sources.py +240 -15
- package/hooks/tests/test_run_hook.sh +0 -114
- package/hooks/tests/test_run_hook_cmd.sh +0 -94
- package/hooks/tests/test_session_start.sh +0 -149
- package/scripts/tests/test_check_manifest_versions.py +0 -217
- package/scripts/tests/test_debate_protocol.py +0 -159
- package/scripts/tests/test_injection_hardening.py +0 -174
- package/scripts/tests/test_select_fidelity_smoke.py +0 -142
- package/scripts/tests/test_validate_citation_contract.py +0 -408
- package/scripts/tests/test_validate_cross_critique.py +0 -149
- package/scripts/tests/test_validate_curriculum_sources.py +0 -144
- package/scripts/tests/test_validate_fidelity.py +0 -59
- package/scripts/tests/test_validate_lore_triggers_content.py +0 -372
- package/scripts/tests/test_validate_persona_fidelity.py +0 -317
- package/scripts/tests/test_validate_promptfoo_configs.py +0 -386
- package/scripts/tests/test_validate_workflow.py +0 -265
- /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
package/skill-catalog.json
CHANGED
|
@@ -6,133 +6,196 @@
|
|
|
6
6
|
"kind": "persona",
|
|
7
7
|
"source": "prebuilt/master-nagarjuna",
|
|
8
8
|
"install_dir": "master-nagarjuna",
|
|
9
|
-
"aliases": [
|
|
9
|
+
"aliases": [
|
|
10
|
+
"nagarjuna",
|
|
11
|
+
"master-nagarjuna"
|
|
12
|
+
]
|
|
10
13
|
},
|
|
11
14
|
{
|
|
12
15
|
"name": "master-xuanzang",
|
|
13
16
|
"kind": "persona",
|
|
14
17
|
"source": "prebuilt/master-xuanzang",
|
|
15
18
|
"install_dir": "master-xuanzang",
|
|
16
|
-
"aliases": [
|
|
19
|
+
"aliases": [
|
|
20
|
+
"xuanzang",
|
|
21
|
+
"master-xuanzang"
|
|
22
|
+
]
|
|
17
23
|
},
|
|
18
24
|
{
|
|
19
25
|
"name": "master-kumarajiva",
|
|
20
26
|
"kind": "persona",
|
|
21
27
|
"source": "prebuilt/master-kumarajiva",
|
|
22
28
|
"install_dir": "master-kumarajiva",
|
|
23
|
-
"aliases": [
|
|
29
|
+
"aliases": [
|
|
30
|
+
"kumarajiva",
|
|
31
|
+
"master-kumarajiva"
|
|
32
|
+
]
|
|
24
33
|
},
|
|
25
34
|
{
|
|
26
35
|
"name": "master-huineng",
|
|
27
36
|
"kind": "persona",
|
|
28
37
|
"source": "prebuilt/master-huineng",
|
|
29
38
|
"install_dir": "master-huineng",
|
|
30
|
-
"aliases": [
|
|
39
|
+
"aliases": [
|
|
40
|
+
"huineng",
|
|
41
|
+
"master-huineng"
|
|
42
|
+
]
|
|
31
43
|
},
|
|
32
44
|
{
|
|
33
45
|
"name": "master-zhiyi",
|
|
34
46
|
"kind": "persona",
|
|
35
47
|
"source": "prebuilt/master-zhiyi",
|
|
36
48
|
"install_dir": "master-zhiyi",
|
|
37
|
-
"aliases": [
|
|
49
|
+
"aliases": [
|
|
50
|
+
"zhiyi",
|
|
51
|
+
"master-zhiyi"
|
|
52
|
+
]
|
|
38
53
|
},
|
|
39
54
|
{
|
|
40
55
|
"name": "master-fazang",
|
|
41
56
|
"kind": "persona",
|
|
42
57
|
"source": "prebuilt/master-fazang",
|
|
43
58
|
"install_dir": "master-fazang",
|
|
44
|
-
"aliases": [
|
|
59
|
+
"aliases": [
|
|
60
|
+
"fazang",
|
|
61
|
+
"master-fazang"
|
|
62
|
+
]
|
|
45
63
|
},
|
|
46
64
|
{
|
|
47
65
|
"name": "master-yinguang",
|
|
48
66
|
"kind": "persona",
|
|
49
67
|
"source": "prebuilt/master-yinguang",
|
|
50
68
|
"install_dir": "master-yinguang",
|
|
51
|
-
"aliases": [
|
|
69
|
+
"aliases": [
|
|
70
|
+
"yinguang",
|
|
71
|
+
"master-yinguang"
|
|
72
|
+
]
|
|
52
73
|
},
|
|
53
74
|
{
|
|
54
75
|
"name": "master-ouyi",
|
|
55
76
|
"kind": "persona",
|
|
56
77
|
"source": "prebuilt/master-ouyi",
|
|
57
78
|
"install_dir": "master-ouyi",
|
|
58
|
-
"aliases": [
|
|
79
|
+
"aliases": [
|
|
80
|
+
"ouyi",
|
|
81
|
+
"master-ouyi"
|
|
82
|
+
]
|
|
59
83
|
},
|
|
60
84
|
{
|
|
61
85
|
"name": "master-xuyun",
|
|
62
86
|
"kind": "persona",
|
|
63
87
|
"source": "prebuilt/master-xuyun",
|
|
64
88
|
"install_dir": "master-xuyun",
|
|
65
|
-
"aliases": [
|
|
89
|
+
"aliases": [
|
|
90
|
+
"xuyun",
|
|
91
|
+
"master-xuyun"
|
|
92
|
+
]
|
|
66
93
|
},
|
|
67
94
|
{
|
|
68
95
|
"name": "master-atisha",
|
|
69
96
|
"kind": "persona",
|
|
70
97
|
"source": "prebuilt/master-atisha",
|
|
71
98
|
"install_dir": "master-atisha",
|
|
72
|
-
"aliases": [
|
|
99
|
+
"aliases": [
|
|
100
|
+
"atisha",
|
|
101
|
+
"master-atisha"
|
|
102
|
+
]
|
|
73
103
|
},
|
|
74
104
|
{
|
|
75
105
|
"name": "master-tsongkhapa",
|
|
76
106
|
"kind": "persona",
|
|
77
107
|
"source": "prebuilt/master-tsongkhapa",
|
|
78
108
|
"install_dir": "master-tsongkhapa",
|
|
79
|
-
"aliases": [
|
|
109
|
+
"aliases": [
|
|
110
|
+
"tsongkhapa",
|
|
111
|
+
"master-tsongkhapa"
|
|
112
|
+
]
|
|
80
113
|
},
|
|
81
114
|
{
|
|
82
115
|
"name": "master-milarepa",
|
|
83
116
|
"kind": "persona",
|
|
84
117
|
"source": "prebuilt/master-milarepa",
|
|
85
118
|
"install_dir": "master-milarepa",
|
|
86
|
-
"aliases": [
|
|
119
|
+
"aliases": [
|
|
120
|
+
"milarepa",
|
|
121
|
+
"master-milarepa"
|
|
122
|
+
]
|
|
87
123
|
},
|
|
88
124
|
{
|
|
89
125
|
"name": "master-buddhaghosa",
|
|
90
126
|
"kind": "persona",
|
|
91
127
|
"source": "prebuilt/master-buddhaghosa",
|
|
92
128
|
"install_dir": "master-buddhaghosa",
|
|
93
|
-
"aliases": [
|
|
129
|
+
"aliases": [
|
|
130
|
+
"buddhaghosa",
|
|
131
|
+
"master-buddhaghosa"
|
|
132
|
+
]
|
|
94
133
|
},
|
|
95
134
|
{
|
|
96
135
|
"name": "master-mahasi-sayadaw",
|
|
97
136
|
"kind": "persona",
|
|
98
137
|
"source": "prebuilt/master-mahasi-sayadaw",
|
|
99
138
|
"install_dir": "master-mahasi-sayadaw",
|
|
100
|
-
"aliases": [
|
|
139
|
+
"aliases": [
|
|
140
|
+
"mahasi-sayadaw",
|
|
141
|
+
"master-mahasi-sayadaw"
|
|
142
|
+
]
|
|
101
143
|
},
|
|
102
144
|
{
|
|
103
145
|
"name": "master-ajahn-chah",
|
|
104
146
|
"kind": "persona",
|
|
105
147
|
"source": "prebuilt/master-ajahn-chah",
|
|
106
148
|
"install_dir": "master-ajahn-chah",
|
|
107
|
-
"aliases": [
|
|
149
|
+
"aliases": [
|
|
150
|
+
"ajahn-chah",
|
|
151
|
+
"master-ajahn-chah"
|
|
152
|
+
]
|
|
108
153
|
},
|
|
109
154
|
{
|
|
110
155
|
"name": "compare-masters",
|
|
111
156
|
"kind": "teaching-mode",
|
|
112
|
-
"source": "prebuilt/compare",
|
|
157
|
+
"source": "prebuilt/compare-masters",
|
|
113
158
|
"install_dir": "compare-masters",
|
|
114
|
-
"aliases": [
|
|
159
|
+
"aliases": [
|
|
160
|
+
"compare-masters"
|
|
161
|
+
]
|
|
115
162
|
},
|
|
116
163
|
{
|
|
117
164
|
"name": "master-debate",
|
|
118
165
|
"kind": "teaching-mode",
|
|
119
166
|
"source": "prebuilt/master-debate",
|
|
120
167
|
"install_dir": "master-debate",
|
|
121
|
-
"aliases": [
|
|
168
|
+
"aliases": [
|
|
169
|
+
"master-debate"
|
|
170
|
+
]
|
|
122
171
|
},
|
|
123
172
|
{
|
|
124
173
|
"name": "master-curriculum",
|
|
125
174
|
"kind": "teaching-mode",
|
|
126
175
|
"source": "prebuilt/master-curriculum",
|
|
127
176
|
"install_dir": "master-curriculum",
|
|
128
|
-
"aliases": [
|
|
177
|
+
"aliases": [
|
|
178
|
+
"master-curriculum"
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"name": "master-help",
|
|
183
|
+
"kind": "teaching-mode",
|
|
184
|
+
"source": "prebuilt/master-help",
|
|
185
|
+
"install_dir": "master-help",
|
|
186
|
+
"aliases": [
|
|
187
|
+
"help",
|
|
188
|
+
"master-help"
|
|
189
|
+
]
|
|
129
190
|
},
|
|
130
191
|
{
|
|
131
192
|
"name": "create-master",
|
|
132
193
|
"kind": "generator",
|
|
133
194
|
"source": ".",
|
|
134
195
|
"install_dir": "create-master",
|
|
135
|
-
"aliases": [
|
|
196
|
+
"aliases": [
|
|
197
|
+
"create-master"
|
|
198
|
+
],
|
|
136
199
|
"bundle_paths": [
|
|
137
200
|
"SKILL.md",
|
|
138
201
|
"tools",
|
package/tools/cross_reference.py
CHANGED
|
@@ -9,6 +9,7 @@ Usage:
|
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
11
|
import json
|
|
12
|
+
import re
|
|
12
13
|
import os
|
|
13
14
|
import sys
|
|
14
15
|
from pathlib import Path
|
|
@@ -18,7 +19,7 @@ from typing import Optional
|
|
|
18
19
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
19
20
|
sys.path.insert(0, str(PROJECT_ROOT / "tools"))
|
|
20
21
|
|
|
21
|
-
from fojin_bridge import FojinBridge, create_bridge
|
|
22
|
+
from fojin_bridge import FojinBridge, FojinConfigError, create_bridge
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
# ── Teacher registry ────────────────────────────────────────────
|
|
@@ -26,9 +27,27 @@ from fojin_bridge import FojinBridge, create_bridge
|
|
|
26
27
|
PREBUILT_DIR = PROJECT_ROOT / "prebuilt"
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
# Slugs reach this module from `--teachers xuanzang,kumarajiva`, split on
|
|
31
|
+
# commas and joined onto PREBUILT_DIR. Without a charset restriction
|
|
32
|
+
# `--teachers ../../../../etc` read a meta.json from anywhere on the machine —
|
|
33
|
+
# verified before this guard existed. Every other entry point in this repo
|
|
34
|
+
# already had one: `_SAFE_MASTER` in scripts/verify_citations.py and
|
|
35
|
+
# scripts/query.py, `isSafeName` in bin/cli.mjs. This file was the gap.
|
|
36
|
+
_SAFE_SLUG = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
37
|
+
|
|
38
|
+
|
|
29
39
|
def load_teacher_meta(slug: str) -> dict:
|
|
30
40
|
"""Load a teacher's meta.json by slug."""
|
|
41
|
+
if not _SAFE_SLUG.match(slug):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"invalid teacher slug: {slug!r} (letters, digits, '-' and '_' only)"
|
|
44
|
+
)
|
|
31
45
|
meta_path = PREBUILT_DIR / slug / "meta.json"
|
|
46
|
+
# Resolved and re-checked rather than trusted to the charset alone: the
|
|
47
|
+
# charset is the rule, this is the assertion that the rule held.
|
|
48
|
+
resolved = meta_path.resolve()
|
|
49
|
+
if not resolved.is_relative_to(PREBUILT_DIR.resolve()):
|
|
50
|
+
raise ValueError(f"teacher slug escapes prebuilt/: {slug!r}")
|
|
32
51
|
if not meta_path.exists():
|
|
33
52
|
raise FileNotFoundError(f"Teacher not found: {slug}")
|
|
34
53
|
with open(meta_path, "r", encoding="utf-8") as f:
|
|
@@ -347,16 +366,31 @@ def main():
|
|
|
347
366
|
parser.print_help()
|
|
348
367
|
return
|
|
349
368
|
|
|
350
|
-
|
|
369
|
+
try:
|
|
370
|
+
bridge = create_bridge()
|
|
371
|
+
except FojinConfigError as error:
|
|
372
|
+
# Matches the contract the other entry points keep: a message a person
|
|
373
|
+
# can act on, and an exit code, instead of a traceback.
|
|
374
|
+
print(f"[配置错误] {error}", file=sys.stderr)
|
|
375
|
+
sys.exit(2)
|
|
351
376
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
377
|
+
try:
|
|
378
|
+
if args.command == "lineage":
|
|
379
|
+
output = cmd_lineage(bridge, args.person_a, args.person_b)
|
|
380
|
+
elif args.command == "concept":
|
|
381
|
+
teacher_slugs = [s.strip() for s in args.teachers.split(",")]
|
|
382
|
+
output = cmd_concept(bridge, args.concept, teacher_slugs)
|
|
383
|
+
else:
|
|
384
|
+
parser.print_help()
|
|
385
|
+
return
|
|
386
|
+
except ValueError as error:
|
|
387
|
+
# Raised by load_teacher_meta for a slug outside the allowed charset.
|
|
388
|
+
# scripts/query.py answers the same mistake the same way, exit 2.
|
|
389
|
+
print(f"{error}", file=sys.stderr)
|
|
390
|
+
sys.exit(2)
|
|
391
|
+
except FileNotFoundError as error:
|
|
392
|
+
print(f"{error}", file=sys.stderr)
|
|
393
|
+
sys.exit(1)
|
|
360
394
|
|
|
361
395
|
print(output)
|
|
362
396
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "FoJin 确认不收录的 CBETA id。每条都必须写明理由与核验日期。verify_sources.py 用它把周检的『Not found』分成『已知缺失』与『新出现的缺失』——前者不再每周开 issue,后者照常。清单本身也会失效:列在这里的 id 若哪天在 FoJin 查得到,同样报告并要求删除该条,所以它不会悄悄烂掉。",
|
|
3
|
+
"absent": [
|
|
4
|
+
{
|
|
5
|
+
"cbeta_id": "J36nB348",
|
|
6
|
+
"short_cbeta_id": "JB348",
|
|
7
|
+
"title": "灵峰宗论",
|
|
8
|
+
"used_by": ["master-ouyi"],
|
|
9
|
+
"reason": "嘉兴藏(J)不在 FoJin 的收录范围内。2026-09-13 以 tools/fojin_bridge.py 直接查证:lookup-cbeta 对 'JB348' 与 'J36nB348' 均返回 {},而同一次请求里的 'T0235' 正常解析为 text_id=7 —— 是缺这部书,不是接口不通。",
|
|
10
|
+
"verified_absent_on": "2026-09-13",
|
|
11
|
+
"consequence": "master-ouyi 引用《灵峰宗论》时拿不到 fojin.app 活链接。离线审计不受影响:J36nB348 在 meta.json 里已声明,verify_citations.py 照常解析。"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
package/tools/fojin_bridge.py
CHANGED
|
@@ -1,30 +1,101 @@
|
|
|
1
1
|
"""
|
|
2
2
|
FoJin Data Bridge — connects Master-skill to FoJin's Buddhist text platform.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Every call goes through the fojin.app REST API. The `mode` argument (and
|
|
5
|
+
`FOJIN_MODE`) is accepted but inert: the "local mode: direct database access"
|
|
6
|
+
this docstring advertised for two years was never implemented, and describing
|
|
7
|
+
an unwritten feature as a shipped one is its own kind of unverified claim.
|
|
7
8
|
"""
|
|
8
9
|
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
9
12
|
import json
|
|
10
13
|
import logging
|
|
11
14
|
import os
|
|
15
|
+
import time
|
|
12
16
|
from typing import Optional
|
|
17
|
+
from urllib.parse import urlparse
|
|
13
18
|
|
|
14
19
|
import requests
|
|
15
20
|
|
|
21
|
+
# A response this big is a broken endpoint or a hostile one, never a real
|
|
22
|
+
# search result — the largest legitimate payload observed is a full juan at
|
|
23
|
+
# well under 2 MB. Without a cap, `resp.json()` reads the whole body into
|
|
24
|
+
# memory first, so a wedged or redirected host can take the process out.
|
|
25
|
+
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
# (connect, read). One scalar timeout applies the same value to both, so a
|
|
28
|
+
# host that accepts the connection and then says nothing held the old code for
|
|
29
|
+
# the full 30s. Connecting is either fast or not happening.
|
|
30
|
+
DEFAULT_TIMEOUT = (5, 30)
|
|
31
|
+
|
|
32
|
+
# Wall clock for a whole streamed body. `stream=True` applies the read timeout
|
|
33
|
+
# to each chunk, not to the transfer, so a slow drip never trips it.
|
|
34
|
+
READ_DEADLINE = 60.0
|
|
35
|
+
|
|
16
36
|
|
|
17
37
|
class FojinUnavailableError(Exception):
|
|
18
38
|
"""Raised when FoJin API is unreachable. Callers should handle gracefully."""
|
|
19
39
|
pass
|
|
20
40
|
|
|
21
41
|
|
|
42
|
+
class FojinConfigError(ValueError):
|
|
43
|
+
"""Raised when FOJIN_URL is not a usable https base URL."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_base_url(url: str) -> str:
|
|
48
|
+
"""Reject a base URL that is not plain https.
|
|
49
|
+
|
|
50
|
+
FOJIN_URL is read from the environment and every request is built on top of
|
|
51
|
+
it, so it decides where citations are verified against. Allowing `http://`
|
|
52
|
+
or a `file://`-ish scheme through would silently downgrade or redirect that
|
|
53
|
+
check.
|
|
54
|
+
|
|
55
|
+
Two deliberate exits from that rule:
|
|
56
|
+
|
|
57
|
+
- `http://localhost` (and 127.0.0.1 / ::1) is always allowed; the traffic
|
|
58
|
+
never leaves the machine.
|
|
59
|
+
- `FOJIN_ALLOW_INSECURE_URL=1` allows any http host, for someone running
|
|
60
|
+
FoJin on a LAN or inside a container network. It is opt-in because the
|
|
61
|
+
default has to be the safe one, and because self-hosting is not
|
|
62
|
+
documented anywhere in this repo — an earlier version of this docstring
|
|
63
|
+
claimed references/fojin-api.md covered the local path; that file does
|
|
64
|
+
not mention it at all.
|
|
65
|
+
"""
|
|
66
|
+
parsed = urlparse(url)
|
|
67
|
+
if parsed.scheme == "https":
|
|
68
|
+
return url.rstrip("/")
|
|
69
|
+
if parsed.scheme == "http":
|
|
70
|
+
if parsed.hostname in ("localhost", "127.0.0.1", "::1"):
|
|
71
|
+
return url.rstrip("/")
|
|
72
|
+
if os.environ.get("FOJIN_ALLOW_INSECURE_URL") == "1":
|
|
73
|
+
logging.getLogger(__name__).warning(
|
|
74
|
+
"FOJIN_URL is plain http (%s); citations will be verified over "
|
|
75
|
+
"an unauthenticated connection", url
|
|
76
|
+
)
|
|
77
|
+
return url.rstrip("/")
|
|
78
|
+
raise FojinConfigError(
|
|
79
|
+
f"FOJIN_URL must be https: {url!r}. http is allowed on localhost, or "
|
|
80
|
+
"anywhere if you set FOJIN_ALLOW_INSECURE_URL=1 deliberately."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
22
84
|
class FojinBridge:
|
|
23
85
|
"""Bridge to FoJin Buddhist text platform."""
|
|
24
86
|
|
|
25
87
|
def __init__(self, mode: str = "api", base_url: str = "https://fojin.app"):
|
|
26
|
-
|
|
27
|
-
|
|
88
|
+
# `mode` has never done anything: the docstring's "local mode: direct
|
|
89
|
+
# database access" was never implemented, and every call goes through
|
|
90
|
+
# the REST path regardless. Kept as an accepted argument so existing
|
|
91
|
+
# callers and FOJIN_MODE=… do not break, but it is now recorded as
|
|
92
|
+
# inert rather than left looking like a feature.
|
|
93
|
+
if mode not in ("api", ""):
|
|
94
|
+
logging.getLogger(__name__).warning(
|
|
95
|
+
"FojinBridge mode=%r is not implemented; using the REST API", mode
|
|
96
|
+
)
|
|
97
|
+
self.mode = "api"
|
|
98
|
+
self.base_url = _validate_base_url(base_url)
|
|
28
99
|
self.session = requests.Session()
|
|
29
100
|
self.session.headers.update({"Accept": "application/json"})
|
|
30
101
|
|
|
@@ -121,14 +192,70 @@ class FojinBridge:
|
|
|
121
192
|
requests.HTTPError: On 4xx/5xx responses
|
|
122
193
|
"""
|
|
123
194
|
url = f"{self.base_url}{path}"
|
|
195
|
+
resp = None
|
|
124
196
|
try:
|
|
125
|
-
|
|
197
|
+
# stream=True so the body is not pulled into memory before its
|
|
198
|
+
# size can be checked.
|
|
199
|
+
resp = self.session.get(
|
|
200
|
+
url, params=params, timeout=DEFAULT_TIMEOUT, stream=True
|
|
201
|
+
)
|
|
126
202
|
resp.raise_for_status()
|
|
127
|
-
|
|
203
|
+
body = self._read_capped(resp)
|
|
128
204
|
except requests.ConnectionError as e:
|
|
129
205
|
raise FojinUnavailableError(f"FoJin API unreachable: {e}") from e
|
|
130
206
|
except requests.Timeout as e:
|
|
131
207
|
raise FojinUnavailableError(f"FoJin API timeout: {e}") from e
|
|
208
|
+
finally:
|
|
209
|
+
# A streamed response holds its connection until closed.
|
|
210
|
+
if resp is not None:
|
|
211
|
+
resp.close()
|
|
212
|
+
return json.loads(body)
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _read_capped(resp, deadline: float | None = None) -> bytearray:
|
|
216
|
+
"""Read a response body, refusing one that is too large or too slow.
|
|
217
|
+
|
|
218
|
+
The size is checked while reading rather than from Content-Length,
|
|
219
|
+
which a hostile or merely chunked response need not send truthfully.
|
|
220
|
+
|
|
221
|
+
Two things the first version missed:
|
|
222
|
+
|
|
223
|
+
- it accumulated a list of chunks and then `b"".join`ed them, so at the
|
|
224
|
+
16 MB cap the peak was ~32 MB — the copy defeats part of the point of
|
|
225
|
+
capping at all. A single bytearray grows in place.
|
|
226
|
+
- `stream=True` moves the read timeout to *per chunk*, so a sender
|
|
227
|
+
dripping one byte inside every read window could hold the connection
|
|
228
|
+
indefinitely without ever tripping the size cap. A wall-clock
|
|
229
|
+
deadline closes that.
|
|
230
|
+
"""
|
|
231
|
+
budget = READ_DEADLINE if deadline is None else None
|
|
232
|
+
limit = time.monotonic() + READ_DEADLINE if deadline is None else deadline
|
|
233
|
+
body = bytearray()
|
|
234
|
+
for chunk in resp.iter_content(64 * 1024):
|
|
235
|
+
body += chunk
|
|
236
|
+
if len(body) > MAX_RESPONSE_BYTES:
|
|
237
|
+
raise FojinUnavailableError(
|
|
238
|
+
f"FoJin API response exceeded {MAX_RESPONSE_BYTES} bytes; "
|
|
239
|
+
"refusing to buffer it"
|
|
240
|
+
)
|
|
241
|
+
if time.monotonic() > limit:
|
|
242
|
+
# Reports the deadline actually in force. The first version
|
|
243
|
+
# interpolated the module constant regardless of what the
|
|
244
|
+
# caller passed, so a 0.05s deadline announced itself as 60s —
|
|
245
|
+
# and the test asserting on that message locked the lie in.
|
|
246
|
+
howlong = (
|
|
247
|
+
f"{budget}s" if budget is not None else "the caller's deadline"
|
|
248
|
+
)
|
|
249
|
+
raise FojinUnavailableError(
|
|
250
|
+
f"FoJin API response took longer than {howlong} to arrive; "
|
|
251
|
+
"abandoning it"
|
|
252
|
+
)
|
|
253
|
+
# The bytearray IS the return value. `bytes(body)` was a second full
|
|
254
|
+
# copy — precisely the allocation `b"".join` used to make — and
|
|
255
|
+
# measured *worse* than the code it replaced: 33.64 MB peak against
|
|
256
|
+
# 32.03 MB, for a 16 MB body. `json.loads` accepts a bytearray, so the
|
|
257
|
+
# copy bought nothing at all.
|
|
258
|
+
return body
|
|
132
259
|
|
|
133
260
|
def test_connection(self) -> bool:
|
|
134
261
|
"""Test if FoJin API is reachable."""
|
|
@@ -140,7 +267,10 @@ class FojinBridge:
|
|
|
140
267
|
|
|
141
268
|
|
|
142
269
|
def create_bridge() -> FojinBridge:
|
|
143
|
-
"""Create a FojinBridge from environment variables.
|
|
270
|
+
"""Create a FojinBridge from environment variables.
|
|
271
|
+
|
|
272
|
+
Raises FojinConfigError if FOJIN_URL is not a usable https base URL.
|
|
273
|
+
"""
|
|
144
274
|
mode = os.environ.get("FOJIN_MODE", "api")
|
|
145
275
|
url = os.environ.get("FOJIN_URL", "https://fojin.app")
|
|
146
276
|
return FojinBridge(mode=mode, base_url=url)
|
package/tools/rag_query.py
CHANGED
|
@@ -17,7 +17,7 @@ import os
|
|
|
17
17
|
# Ensure tools/ is on the path so we can import fojin_bridge
|
|
18
18
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
19
19
|
|
|
20
|
-
from fojin_bridge import create_bridge, FojinUnavailableError
|
|
20
|
+
from fojin_bridge import create_bridge, FojinConfigError, FojinUnavailableError
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def source_identity(item: dict) -> tuple[str, str]:
|
|
@@ -243,14 +243,37 @@ _CONTROL_CHARS = re.compile(
|
|
|
243
243
|
)
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
# Everything this tool prints lands in an agent's context window, and the
|
|
247
|
+
# formatters cap each snippet but not how many there are — they render whatever
|
|
248
|
+
# `items` the endpoint returned. Asking for `--top_k 5` and being handed 10,000
|
|
249
|
+
# produced 6.4 million characters in a measurement, from a service this repo
|
|
250
|
+
# does not control and whose data is enriched from third-party editable sources.
|
|
251
|
+
#
|
|
252
|
+
# The cap lives here rather than in each formatter because `emit` is the single
|
|
253
|
+
# place all four commands pass through: a formatter added later cannot route
|
|
254
|
+
# around it. Truncation is announced, so a short answer is never mistaken for a
|
|
255
|
+
# complete one.
|
|
256
|
+
MAX_EMIT_CHARS = 60_000
|
|
257
|
+
|
|
258
|
+
|
|
246
259
|
def emit(body: str) -> None:
|
|
247
260
|
"""Print a retrieval result wrapped in a data boundary, with control chars
|
|
248
|
-
and any forged boundary lines stripped so the fence can't be broken out of
|
|
261
|
+
and any forged boundary lines stripped so the fence can't be broken out of,
|
|
262
|
+
and the whole thing capped so it cannot flood the caller's context."""
|
|
249
263
|
cleaned = _CONTROL_CHARS.sub("", body or "")
|
|
250
264
|
# Loop until stable — a single replace pass is defeatable by overlapping
|
|
251
265
|
# boundary lines that rejoin into a fresh marker after the inner one is cut.
|
|
252
266
|
while _EMIT_HEADER in cleaned or _EMIT_FOOTER in cleaned:
|
|
253
267
|
cleaned = cleaned.replace(_EMIT_HEADER, "").replace(_EMIT_FOOTER, "")
|
|
268
|
+
if len(cleaned) > MAX_EMIT_CHARS:
|
|
269
|
+
# Truncated AFTER the boundary strip, so the cut cannot leave a partial
|
|
270
|
+
# marker behind that the next pass would not see.
|
|
271
|
+
dropped = len(cleaned) - MAX_EMIT_CHARS
|
|
272
|
+
cleaned = (
|
|
273
|
+
cleaned[:MAX_EMIT_CHARS]
|
|
274
|
+
+ f"\n[已截断:检索结果超过 {MAX_EMIT_CHARS} 字符,省略 {dropped} 字符。"
|
|
275
|
+
"这不是完整结果 —— 缩小查询范围或降低 --top_k 后重试。]"
|
|
276
|
+
)
|
|
254
277
|
print(f"{_EMIT_HEADER}\n{cleaned}\n{_EMIT_FOOTER}")
|
|
255
278
|
|
|
256
279
|
|
|
@@ -316,8 +339,28 @@ def main():
|
|
|
316
339
|
|
|
317
340
|
try:
|
|
318
341
|
args.func(args)
|
|
342
|
+
except FojinConfigError as e:
|
|
343
|
+
# A misconfigured FOJIN_URL is not the same as FoJin being down, but it
|
|
344
|
+
# reaches the agent the same way: no retrieval. Routing it through the
|
|
345
|
+
# documented degradation contract (the marker on stdout, exit 0 — see
|
|
346
|
+
# prompts/rag_instructions.md §50) keeps a self-hoster's typo from
|
|
347
|
+
# blowing up a `set -e` pipeline the agent wrote, while the operator
|
|
348
|
+
# gets the actionable reason on stderr.
|
|
349
|
+
print("[FoJin API 当前不可用]")
|
|
350
|
+
print("配置有误,未发起检索。法师将仅基于预置 teaching.md 回答。")
|
|
351
|
+
print(f"[配置错误] {e}", file=sys.stderr)
|
|
352
|
+
sys.exit(0)
|
|
319
353
|
except FojinUnavailableError:
|
|
354
|
+
# exit 0 是**故意**的,不是本仓一直在修的那个「假绿」形状 ——
|
|
355
|
+
# 这里的调用方是读 stdout 的 agent,不是读退出码的脚本:
|
|
356
|
+
# prompts/rag_instructions.md §50 规定它按 "[FoJin API 当前不可用]"
|
|
357
|
+
# 这行字判断降级。非零退出会让 agent 顺手写的 `set -e` 管道整条炸掉,
|
|
358
|
+
# 换来的是它本来就不看的一个信号。
|
|
359
|
+
# 同一行同时写到 stderr,好让确实在看退出状态的脚本至少能在日志里
|
|
360
|
+
# 分辨「检索不到」与「检索成功」。契约由
|
|
361
|
+
# tests/test_rag_query.py::test_unavailable_* 钉住。
|
|
320
362
|
print("[FoJin API 当前不可用]")
|
|
363
|
+
print("[FoJin API 当前不可用] 无检索结果,输出仅来自预置内容", file=sys.stderr)
|
|
321
364
|
print("无法检索真实经文。法师将仅基于预置 teaching.md 回答。")
|
|
322
365
|
print("建议:")
|
|
323
366
|
print("- 稍后重试")
|