fojin-mcp 0.1.0__tar.gz
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.
- fojin_mcp-0.1.0/.gitignore +5 -0
- fojin_mcp-0.1.0/PKG-INFO +140 -0
- fojin_mcp-0.1.0/README.md +115 -0
- fojin_mcp-0.1.0/fojin_mcp/__init__.py +3 -0
- fojin_mcp-0.1.0/fojin_mcp/__main__.py +6 -0
- fojin_mcp-0.1.0/fojin_mcp/client.py +298 -0
- fojin_mcp-0.1.0/fojin_mcp/py.typed +0 -0
- fojin_mcp-0.1.0/fojin_mcp/server.py +112 -0
- fojin_mcp-0.1.0/fojin_mcp/urn.py +75 -0
- fojin_mcp-0.1.0/pyproject.toml +49 -0
- fojin_mcp-0.1.0/tests/test_client.py +208 -0
- fojin_mcp-0.1.0/tests/test_urn.py +41 -0
fojin_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fojin-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server exposing fojin's verified cross-canon Buddhist corpus (cited, URN-addressable passages) to AI research tools.
|
|
5
|
+
Project-URL: Homepage, https://fojin.app
|
|
6
|
+
Project-URL: Repository, https://github.com/xr843/fojin
|
|
7
|
+
Project-URL: Issues, https://github.com/xr843/fojin/issues
|
|
8
|
+
Author: fojin (佛津)
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: buddhism,cbeta,digital-humanities,llm-tools,mcp,rag
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Religion
|
|
16
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: mcp>=1.2
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# fojin MCP server
|
|
27
|
+
|
|
28
|
+
An [MCP](https://modelcontextprotocol.io) server that exposes **fojin's verified
|
|
29
|
+
cross-canon Buddhist corpus** to any MCP client (Claude Desktop, ChatGPT,
|
|
30
|
+
research tooling). It turns fojin from "an app you visit" into **infrastructure
|
|
31
|
+
an AI can call** — every passage it returns carries a portable, resolvable
|
|
32
|
+
citation (`fojin:cbeta/T0251.1`), so a model can ground and deep-link its claims
|
|
33
|
+
instead of inventing scripture.
|
|
34
|
+
|
|
35
|
+
Read-only by construction: it only issues GETs against fojin's public API.
|
|
36
|
+
|
|
37
|
+
## Tools
|
|
38
|
+
|
|
39
|
+
| Tool | What it returns |
|
|
40
|
+
|------|-----------------|
|
|
41
|
+
| `search_corpus(query, limit, lang)` | Semantic search hits, each with a `urn`, title, snippet, score |
|
|
42
|
+
| `read_passage(text_id, juan_num)` | The actual content of one fascicle (卷) + its `urn` |
|
|
43
|
+
| `get_parallels(text_id, juan_num)` | Cross-canon aligned parallels (Chinese↔Pali↔Tibetan), each with a `urn` and deep-link `reader_ref` — the alignment moat |
|
|
44
|
+
| `lookup_dictionary(term, limit)` | Entries across fojin's 32 dictionaries |
|
|
45
|
+
| `lookup_entity(query, limit)` | Knowledge-graph entities (people, places, works, terms) |
|
|
46
|
+
| `resolve_urn(urn)` | Resolve/verify a `fojin:` URN → reader URL + existence |
|
|
47
|
+
|
|
48
|
+
Every result that names a canonical passage carries a **`urn`** — fojin's stable
|
|
49
|
+
cross-canon identifier, interoperable with CBETA / SuttaCentral (`sc/`) / 84000
|
|
50
|
+
(`84k/toh`) / GRETIL / VRI numbering. Pass it back to `resolve_urn`, or cite it
|
|
51
|
+
directly.
|
|
52
|
+
|
|
53
|
+
## Install & run
|
|
54
|
+
|
|
55
|
+
Once published to PyPI, no clone needed — run it with `uvx` (nothing to
|
|
56
|
+
install) or `pip`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uvx fojin-mcp # zero-install, stdio transport (the MCP default)
|
|
60
|
+
# or
|
|
61
|
+
pip install fojin-mcp && fojin-mcp
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
From a checkout (development):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cd mcp-server && pip install -e . && python -m fojin_mcp
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
By default it talks to `https://fojin.app/api`. Point it elsewhere (self-host,
|
|
71
|
+
staging) with an env var:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
FOJIN_API_BASE_URL=http://localhost:8000/api uvx fojin-mcp
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Claude Desktop
|
|
78
|
+
|
|
79
|
+
Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"mcpServers": {
|
|
84
|
+
"fojin": {
|
|
85
|
+
"command": "uvx",
|
|
86
|
+
"args": ["fojin-mcp"]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
(or `"command": "fojin-mcp"` if you `pip install`ed it, or `python -m fojin_mcp`
|
|
93
|
+
from a checkout.) Restart Claude Desktop; the six fojin tools then appear.
|
|
94
|
+
|
|
95
|
+
### ChatGPT / other MCP clients
|
|
96
|
+
|
|
97
|
+
Any client that speaks MCP over stdio can launch `uvx fojin-mcp` (or the
|
|
98
|
+
`fojin-mcp` console script) and will discover the six tools automatically.
|
|
99
|
+
|
|
100
|
+
## Design
|
|
101
|
+
|
|
102
|
+
- **Standalone.** Talks to fojin only over HTTP; it does not import the backend,
|
|
103
|
+
so it installs and runs independently (deps: `mcp`, `httpx`).
|
|
104
|
+
- **URNs built client-side.** fojin's search/read/alignment endpoints expose
|
|
105
|
+
`cbeta_id`; the server builds the `urn` from it (`fojin_mcp/urn.py`, a vendored
|
|
106
|
+
copy of the backend's `build_urn`, kept behaviourally identical and
|
|
107
|
+
round-trip-tested). So citations work today, independent of any server change.
|
|
108
|
+
`get_parallels` enriches each parallel's URN via a bounded, concurrent,
|
|
109
|
+
best-effort `text_id → cbeta_id` lookup (a miss just leaves `urn: null`).
|
|
110
|
+
- **Testable core.** All HTTP + reshaping lives in `client.py` and is tested
|
|
111
|
+
against a mocked transport; `server.py` is a thin FastMCP wiring layer.
|
|
112
|
+
- **Fails soft.** An upstream error returns `{"error": "..."}` to the model
|
|
113
|
+
rather than crashing the tool call.
|
|
114
|
+
|
|
115
|
+
## Test
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install -e '.[dev]'
|
|
119
|
+
pytest -q # unit tests (no network)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Publishing (maintainer)
|
|
123
|
+
|
|
124
|
+
CI (`.github/workflows/mcp-server.yml`) lints, tests, and builds the package on
|
|
125
|
+
every change under `mcp-server/`. To release to PyPI, bump `version` in
|
|
126
|
+
`pyproject.toml`, then push a tag:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
git tag mcp-v0.1.0 && git push origin mcp-v0.1.0
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`.github/workflows/mcp-publish.yml` builds and uploads. It needs credentials
|
|
133
|
+
once — set up **either** PyPI Trusted Publishing (recommended; no secret) for
|
|
134
|
+
project `fojin-mcp` / repo `xr843/fojin` / workflow `mcp-publish.yml` /
|
|
135
|
+
environment `pypi`, **or** a `PYPI_API_TOKEN` repo secret (then uncomment the
|
|
136
|
+
`password:` line in the workflow).
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
Apache-2.0 (same as fojin).
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# fojin MCP server
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that exposes **fojin's verified
|
|
4
|
+
cross-canon Buddhist corpus** to any MCP client (Claude Desktop, ChatGPT,
|
|
5
|
+
research tooling). It turns fojin from "an app you visit" into **infrastructure
|
|
6
|
+
an AI can call** — every passage it returns carries a portable, resolvable
|
|
7
|
+
citation (`fojin:cbeta/T0251.1`), so a model can ground and deep-link its claims
|
|
8
|
+
instead of inventing scripture.
|
|
9
|
+
|
|
10
|
+
Read-only by construction: it only issues GETs against fojin's public API.
|
|
11
|
+
|
|
12
|
+
## Tools
|
|
13
|
+
|
|
14
|
+
| Tool | What it returns |
|
|
15
|
+
|------|-----------------|
|
|
16
|
+
| `search_corpus(query, limit, lang)` | Semantic search hits, each with a `urn`, title, snippet, score |
|
|
17
|
+
| `read_passage(text_id, juan_num)` | The actual content of one fascicle (卷) + its `urn` |
|
|
18
|
+
| `get_parallels(text_id, juan_num)` | Cross-canon aligned parallels (Chinese↔Pali↔Tibetan), each with a `urn` and deep-link `reader_ref` — the alignment moat |
|
|
19
|
+
| `lookup_dictionary(term, limit)` | Entries across fojin's 32 dictionaries |
|
|
20
|
+
| `lookup_entity(query, limit)` | Knowledge-graph entities (people, places, works, terms) |
|
|
21
|
+
| `resolve_urn(urn)` | Resolve/verify a `fojin:` URN → reader URL + existence |
|
|
22
|
+
|
|
23
|
+
Every result that names a canonical passage carries a **`urn`** — fojin's stable
|
|
24
|
+
cross-canon identifier, interoperable with CBETA / SuttaCentral (`sc/`) / 84000
|
|
25
|
+
(`84k/toh`) / GRETIL / VRI numbering. Pass it back to `resolve_urn`, or cite it
|
|
26
|
+
directly.
|
|
27
|
+
|
|
28
|
+
## Install & run
|
|
29
|
+
|
|
30
|
+
Once published to PyPI, no clone needed — run it with `uvx` (nothing to
|
|
31
|
+
install) or `pip`:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uvx fojin-mcp # zero-install, stdio transport (the MCP default)
|
|
35
|
+
# or
|
|
36
|
+
pip install fojin-mcp && fojin-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
From a checkout (development):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cd mcp-server && pip install -e . && python -m fojin_mcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
By default it talks to `https://fojin.app/api`. Point it elsewhere (self-host,
|
|
46
|
+
staging) with an env var:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
FOJIN_API_BASE_URL=http://localhost:8000/api uvx fojin-mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Claude Desktop
|
|
53
|
+
|
|
54
|
+
Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"fojin": {
|
|
60
|
+
"command": "uvx",
|
|
61
|
+
"args": ["fojin-mcp"]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
(or `"command": "fojin-mcp"` if you `pip install`ed it, or `python -m fojin_mcp`
|
|
68
|
+
from a checkout.) Restart Claude Desktop; the six fojin tools then appear.
|
|
69
|
+
|
|
70
|
+
### ChatGPT / other MCP clients
|
|
71
|
+
|
|
72
|
+
Any client that speaks MCP over stdio can launch `uvx fojin-mcp` (or the
|
|
73
|
+
`fojin-mcp` console script) and will discover the six tools automatically.
|
|
74
|
+
|
|
75
|
+
## Design
|
|
76
|
+
|
|
77
|
+
- **Standalone.** Talks to fojin only over HTTP; it does not import the backend,
|
|
78
|
+
so it installs and runs independently (deps: `mcp`, `httpx`).
|
|
79
|
+
- **URNs built client-side.** fojin's search/read/alignment endpoints expose
|
|
80
|
+
`cbeta_id`; the server builds the `urn` from it (`fojin_mcp/urn.py`, a vendored
|
|
81
|
+
copy of the backend's `build_urn`, kept behaviourally identical and
|
|
82
|
+
round-trip-tested). So citations work today, independent of any server change.
|
|
83
|
+
`get_parallels` enriches each parallel's URN via a bounded, concurrent,
|
|
84
|
+
best-effort `text_id → cbeta_id` lookup (a miss just leaves `urn: null`).
|
|
85
|
+
- **Testable core.** All HTTP + reshaping lives in `client.py` and is tested
|
|
86
|
+
against a mocked transport; `server.py` is a thin FastMCP wiring layer.
|
|
87
|
+
- **Fails soft.** An upstream error returns `{"error": "..."}` to the model
|
|
88
|
+
rather than crashing the tool call.
|
|
89
|
+
|
|
90
|
+
## Test
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install -e '.[dev]'
|
|
94
|
+
pytest -q # unit tests (no network)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Publishing (maintainer)
|
|
98
|
+
|
|
99
|
+
CI (`.github/workflows/mcp-server.yml`) lints, tests, and builds the package on
|
|
100
|
+
every change under `mcp-server/`. To release to PyPI, bump `version` in
|
|
101
|
+
`pyproject.toml`, then push a tag:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git tag mcp-v0.1.0 && git push origin mcp-v0.1.0
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`.github/workflows/mcp-publish.yml` builds and uploads. It needs credentials
|
|
108
|
+
once — set up **either** PyPI Trusted Publishing (recommended; no secret) for
|
|
109
|
+
project `fojin-mcp` / repo `xr843/fojin` / workflow `mcp-publish.yml` /
|
|
110
|
+
environment `pypi`, **or** a `PYPI_API_TOKEN` repo secret (then uncomment the
|
|
111
|
+
`password:` line in the workflow).
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
Apache-2.0 (same as fojin).
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Async HTTP client over fojin's public read-only API, plus the pure functions
|
|
2
|
+
that reshape each endpoint's response into a citation-carrying tool result.
|
|
3
|
+
|
|
4
|
+
Design: everything here is import-light (httpx only, no ``mcp``) so the reshaping
|
|
5
|
+
logic is unit-testable with a mocked transport, independent of the MCP runtime.
|
|
6
|
+
``server.py`` is the thin layer that registers these as MCP tools.
|
|
7
|
+
|
|
8
|
+
Every result that identifies a canonical passage carries a ``urn`` — the whole
|
|
9
|
+
point of the server: an AI calling these tools gets back a portable, resolvable
|
|
10
|
+
citation (``fojin:cbeta/T0251.1``) it can cite and deep-link, not an opaque
|
|
11
|
+
internal id. Read-only: no endpoint here mutates fojin state.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from .urn import build_urn
|
|
22
|
+
|
|
23
|
+
# Cap on per-call text→cbeta_id lookups when enriching parallels with URNs, so a
|
|
24
|
+
# pathological juan with many distinct aligned texts can't fan out unboundedly.
|
|
25
|
+
_MAX_URN_ENRICH_LOOKUPS = 20
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE_URL = "https://fojin.app/api"
|
|
28
|
+
DEFAULT_TIMEOUT = 20.0
|
|
29
|
+
# fojin is a scholarly public good; identify the client so its traffic is
|
|
30
|
+
# attributable and it can be rate-limited/allow-listed distinctly from browsers.
|
|
31
|
+
USER_AGENT = "fojin-mcp/0.1 (+https://github.com/xr843/fojin)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FojinAPIError(RuntimeError):
|
|
35
|
+
"""A fojin API call failed (network, timeout, or non-2xx). The message is
|
|
36
|
+
safe to surface to the calling model as a tool error."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FojinClient:
|
|
40
|
+
"""Thin async wrapper over the fojin HTTP API.
|
|
41
|
+
|
|
42
|
+
One client owns one ``httpx.AsyncClient``; use it as an async context
|
|
43
|
+
manager so the connection pool is closed deterministically. ``base_url``
|
|
44
|
+
defaults to prod but is overridable (env/tests/self-host).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
*,
|
|
51
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
52
|
+
client: httpx.AsyncClient | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._base_url = base_url.rstrip("/")
|
|
55
|
+
# Injectable client so tests pass a mocked transport; otherwise we own it.
|
|
56
|
+
self._client = client or httpx.AsyncClient(
|
|
57
|
+
timeout=timeout, headers={"User-Agent": USER_AGENT}
|
|
58
|
+
)
|
|
59
|
+
self._owns_client = client is None
|
|
60
|
+
|
|
61
|
+
async def __aenter__(self) -> FojinClient:
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
65
|
+
await self.aclose()
|
|
66
|
+
|
|
67
|
+
async def aclose(self) -> None:
|
|
68
|
+
if self._owns_client:
|
|
69
|
+
await self._client.aclose()
|
|
70
|
+
|
|
71
|
+
async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
72
|
+
url = f"{self._base_url}{path}"
|
|
73
|
+
try:
|
|
74
|
+
resp = await self._client.get(url, params=params)
|
|
75
|
+
resp.raise_for_status()
|
|
76
|
+
return resp.json()
|
|
77
|
+
except httpx.HTTPStatusError as exc:
|
|
78
|
+
raise FojinAPIError(
|
|
79
|
+
f"fojin API {exc.response.status_code} for {path}"
|
|
80
|
+
) from exc
|
|
81
|
+
except httpx.HTTPError as exc:
|
|
82
|
+
raise FojinAPIError(f"fojin API request failed for {path}: {exc}") from exc
|
|
83
|
+
|
|
84
|
+
# ── tools ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
async def search_corpus(
|
|
87
|
+
self, query: str, limit: int = 10, lang: str | None = None
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Semantic search across the corpus → citation-carrying hits."""
|
|
90
|
+
params: dict[str, Any] = {"q": query, "size": _clamp(limit, 1, 50)}
|
|
91
|
+
if lang:
|
|
92
|
+
params["lang"] = lang
|
|
93
|
+
data = await self._get("/search/semantic", params)
|
|
94
|
+
return shape_search_results(data)
|
|
95
|
+
|
|
96
|
+
async def read_passage(self, text_id: int, juan_num: int) -> dict[str, Any]:
|
|
97
|
+
"""Fetch the actual content of one fascicle (卷) with its URN."""
|
|
98
|
+
data = await self._get(f"/texts/{text_id}/juans/{juan_num}")
|
|
99
|
+
return shape_passage(data)
|
|
100
|
+
|
|
101
|
+
async def get_parallels(self, text_id: int, juan_num: int) -> dict[str, Any]:
|
|
102
|
+
"""Cross-canon aligned parallels for a fascicle (the alignment moat).
|
|
103
|
+
|
|
104
|
+
The alignment endpoint returns parallels keyed by internal text_id (no
|
|
105
|
+
cbeta_id), so we enrich each fojin-native parallel with a portable URN by
|
|
106
|
+
resolving its distinct text_ids → cbeta_id in a bounded, concurrent,
|
|
107
|
+
best-effort pass. A lookup miss just leaves that parallel's urn None;
|
|
108
|
+
it never fails the whole call.
|
|
109
|
+
"""
|
|
110
|
+
data = await self._get(f"/alignment/texts/{text_id}/juans/{juan_num}")
|
|
111
|
+
shaped = shape_parallels(data)
|
|
112
|
+
await self._enrich_parallel_urns(shaped["parallels"])
|
|
113
|
+
return shaped
|
|
114
|
+
|
|
115
|
+
async def _text_cbeta_id(self, text_id: int) -> str | None:
|
|
116
|
+
"""Best-effort text_id → cbeta_id via /texts/{id}; None on any failure."""
|
|
117
|
+
try:
|
|
118
|
+
meta = await self._get(f"/texts/{text_id}")
|
|
119
|
+
except FojinAPIError:
|
|
120
|
+
return None
|
|
121
|
+
return meta.get("cbeta_id") if isinstance(meta, dict) else None
|
|
122
|
+
|
|
123
|
+
async def _enrich_parallel_urns(self, parallels: list[dict[str, Any]]) -> None:
|
|
124
|
+
"""Fill each parallel's ``urn`` in place from its reader_ref.text_id.
|
|
125
|
+
|
|
126
|
+
Only fojin-native parallels (real text_id > 0) are resolvable; inline
|
|
127
|
+
MITRA foreign sentences (text_id 0) have no fojin work to cite and keep
|
|
128
|
+
urn=None. Distinct text_ids are fetched once, concurrently, capped."""
|
|
129
|
+
want = [
|
|
130
|
+
p for p in parallels
|
|
131
|
+
if p.get("urn") is None
|
|
132
|
+
and isinstance(p.get("reader_ref"), dict)
|
|
133
|
+
and isinstance(p["reader_ref"].get("text_id"), int)
|
|
134
|
+
and p["reader_ref"]["text_id"] > 0
|
|
135
|
+
]
|
|
136
|
+
distinct_ids = list({p["reader_ref"]["text_id"] for p in want})[:_MAX_URN_ENRICH_LOOKUPS]
|
|
137
|
+
if not distinct_ids:
|
|
138
|
+
return
|
|
139
|
+
cbeta_by_id = dict(
|
|
140
|
+
zip(
|
|
141
|
+
distinct_ids,
|
|
142
|
+
await asyncio.gather(*(self._text_cbeta_id(tid) for tid in distinct_ids)),
|
|
143
|
+
strict=True,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
for p in want:
|
|
147
|
+
ref = p["reader_ref"]
|
|
148
|
+
cbeta_id = cbeta_by_id.get(ref["text_id"])
|
|
149
|
+
if cbeta_id:
|
|
150
|
+
p["urn"] = build_urn(cbeta_id, ref.get("juan_num"))
|
|
151
|
+
|
|
152
|
+
async def lookup_dictionary(self, term: str, limit: int = 10) -> dict[str, Any]:
|
|
153
|
+
"""Buddhist dictionary lookup across fojin's 32 dictionaries."""
|
|
154
|
+
data = await self._get(
|
|
155
|
+
"/dictionary/search", {"q": term, "size": _clamp(limit, 1, 50)}
|
|
156
|
+
)
|
|
157
|
+
return {"query": term, "entries": _as_list(data, "results", "entries")}
|
|
158
|
+
|
|
159
|
+
async def lookup_entity(self, query: str, limit: int = 10) -> dict[str, Any]:
|
|
160
|
+
"""Knowledge-graph entity search (persons, places, works, terms)."""
|
|
161
|
+
data = await self._get(
|
|
162
|
+
"/kg/entities", {"q": query, "size": _clamp(limit, 1, 50)}
|
|
163
|
+
)
|
|
164
|
+
return {"query": query, "entities": _as_list(data, "results", "entities")}
|
|
165
|
+
|
|
166
|
+
async def resolve_urn(self, urn: str) -> dict[str, Any]:
|
|
167
|
+
"""Resolve a fojin URN → reader URL + existence (server-authoritative)."""
|
|
168
|
+
# Anchor '#' must be percent-encoded or the server sees an anchorless URN.
|
|
169
|
+
return await self._get("/urn/resolve", {"urn": urn.replace("#", "%23")})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── pure reshaping (unit-tested with plain dicts) ────────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def shape_search_results(data: Any) -> dict[str, Any]:
|
|
176
|
+
"""Normalize /search/semantic into hits that each carry a URN.
|
|
177
|
+
|
|
178
|
+
Robust to the two shapes the field has had: a top-level ``results`` list of
|
|
179
|
+
``SemanticSearchHit``. Each hit gets a juan-level ``urn`` built from its
|
|
180
|
+
``cbeta_id`` (None when the source has no round-trippable id)."""
|
|
181
|
+
hits_in = _as_list(data, "results")
|
|
182
|
+
hits_out: list[dict[str, Any]] = []
|
|
183
|
+
for h in hits_in:
|
|
184
|
+
if not isinstance(h, dict):
|
|
185
|
+
continue
|
|
186
|
+
hits_out.append(
|
|
187
|
+
{
|
|
188
|
+
"urn": build_urn(h.get("cbeta_id"), h.get("juan_num")),
|
|
189
|
+
"text_id": h.get("text_id"),
|
|
190
|
+
"juan_num": h.get("juan_num"),
|
|
191
|
+
"title_zh": h.get("title_zh"),
|
|
192
|
+
"cbeta_id": h.get("cbeta_id"),
|
|
193
|
+
"snippet": h.get("snippet"),
|
|
194
|
+
"score": h.get("similarity_score", h.get("score")),
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
return {"total": _as_int(data, "total", len(hits_out)), "results": hits_out}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def shape_passage(data: Any) -> dict[str, Any]:
|
|
201
|
+
"""Normalize a JuanContentResponse, adding its URN."""
|
|
202
|
+
d = data if isinstance(data, dict) else {}
|
|
203
|
+
return {
|
|
204
|
+
"urn": build_urn(d.get("cbeta_id"), d.get("juan_num")),
|
|
205
|
+
"text_id": d.get("text_id"),
|
|
206
|
+
"cbeta_id": d.get("cbeta_id"),
|
|
207
|
+
"title_zh": d.get("title_zh"),
|
|
208
|
+
"juan_num": d.get("juan_num"),
|
|
209
|
+
"total_juans": d.get("total_juans"),
|
|
210
|
+
"lang": d.get("lang", "lzh"),
|
|
211
|
+
"content": d.get("content"),
|
|
212
|
+
"char_count": d.get("char_count"),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def shape_parallels(data: Any) -> dict[str, Any]:
|
|
217
|
+
"""Flatten a JuanAlignmentResponse into a list of parallel passages.
|
|
218
|
+
|
|
219
|
+
The real envelope is ``entries[] -> {chunk_index, chunk_text, parallels[]}``
|
|
220
|
+
where each parallel is a ``ParallelPair`` keyed by internal text_id (NOT
|
|
221
|
+
cbeta_id). We flatten to one row per parallel, tagged with the source chunk
|
|
222
|
+
it aligns to, and carry each parallel's ``reader_ref`` = {text_id, juan_num,
|
|
223
|
+
chunk_index} — fojin's real deep-link handle. ``urn`` starts None here and is
|
|
224
|
+
filled by the client's bounded text_id→cbeta_id enrichment pass (a
|
|
225
|
+
``ParallelPair`` carries no cbeta_id to build one from inline).
|
|
226
|
+
|
|
227
|
+
``source="mitra-parallel"`` rows are inline foreign (Skt/Tib) sentences from
|
|
228
|
+
MITRA with text_id 0 — no fojin work to cite, so they stay urn=None but keep
|
|
229
|
+
their ``original_preview``/``original_lang`` text."""
|
|
230
|
+
d = data if isinstance(data, dict) else {}
|
|
231
|
+
out: list[dict[str, Any]] = []
|
|
232
|
+
for entry in _as_list(d, "entries"):
|
|
233
|
+
if not isinstance(entry, dict):
|
|
234
|
+
continue
|
|
235
|
+
src_chunk = entry.get("chunk_index")
|
|
236
|
+
for p in _as_list(entry, "parallels"):
|
|
237
|
+
if not isinstance(p, dict):
|
|
238
|
+
continue
|
|
239
|
+
out.append(
|
|
240
|
+
{
|
|
241
|
+
"urn": None, # enriched by FojinClient._enrich_parallel_urns
|
|
242
|
+
"lang": p.get("lang"),
|
|
243
|
+
"title": p.get("title") or "",
|
|
244
|
+
"text": p.get("chunk_text"),
|
|
245
|
+
"confidence": p.get("confidence"),
|
|
246
|
+
"source": p.get("source", "fojin"),
|
|
247
|
+
"original_preview": p.get("original_preview"),
|
|
248
|
+
"original_lang": p.get("original_lang"),
|
|
249
|
+
"aligns_source_chunk": src_chunk,
|
|
250
|
+
"reader_ref": {
|
|
251
|
+
"text_id": p.get("text_id"),
|
|
252
|
+
"juan_num": p.get("juan_num"),
|
|
253
|
+
"chunk_index": p.get("chunk_index"),
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
return {
|
|
258
|
+
"source": {
|
|
259
|
+
"text_id": d.get("text_id"),
|
|
260
|
+
"juan_num": d.get("juan_num"),
|
|
261
|
+
"total_chunks": d.get("total_chunks"),
|
|
262
|
+
"chunks_with_parallels": d.get("chunks_with_parallels"),
|
|
263
|
+
},
|
|
264
|
+
"parallels": out,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── small helpers ────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _clamp(n: int, lo: int, hi: int) -> int:
|
|
272
|
+
try:
|
|
273
|
+
n = int(n)
|
|
274
|
+
except (TypeError, ValueError):
|
|
275
|
+
return lo
|
|
276
|
+
return max(lo, min(hi, n))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _as_list(data: Any, *keys: str) -> list[Any]:
|
|
280
|
+
"""Return the first key in ``data`` whose value is a list; [] if none.
|
|
281
|
+
|
|
282
|
+
Tolerates both ``{"results": [...]}`` envelopes and a bare top-level list."""
|
|
283
|
+
if isinstance(data, list):
|
|
284
|
+
return data
|
|
285
|
+
if isinstance(data, dict):
|
|
286
|
+
for key in keys:
|
|
287
|
+
val = data.get(key)
|
|
288
|
+
if isinstance(val, list):
|
|
289
|
+
return val
|
|
290
|
+
return []
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _as_int(data: Any, key: str, default: int) -> int:
|
|
294
|
+
if isinstance(data, dict):
|
|
295
|
+
val = data.get(key)
|
|
296
|
+
if isinstance(val, int):
|
|
297
|
+
return val
|
|
298
|
+
return default
|
|
File without changes
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""fojin MCP server — exposes fojin's verified cross-canon Buddhist corpus as
|
|
2
|
+
tools any MCP client (Claude, ChatGPT, research tooling) can call.
|
|
3
|
+
|
|
4
|
+
Thin layer: each tool delegates to :class:`fojin_mcp.client.FojinClient` (the
|
|
5
|
+
tested core) and returns its citation-carrying result. The design promise is
|
|
6
|
+
that every passage a tool surfaces comes with a portable ``urn``
|
|
7
|
+
(``fojin:cbeta/T0251.1``) the calling model can cite and resolve — the AI-facing
|
|
8
|
+
side of fojin's "每一句都能点回原典" contract.
|
|
9
|
+
|
|
10
|
+
Read-only by construction: the client only issues GETs against public
|
|
11
|
+
endpoints. Configure the target with ``FOJIN_API_BASE_URL`` (defaults to prod).
|
|
12
|
+
|
|
13
|
+
Run over stdio (the default MCP transport):
|
|
14
|
+
|
|
15
|
+
python -m fojin_mcp
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from mcp.server.fastmcp import FastMCP
|
|
24
|
+
|
|
25
|
+
from .client import DEFAULT_BASE_URL, FojinAPIError, FojinClient
|
|
26
|
+
|
|
27
|
+
_BASE_URL = os.environ.get("FOJIN_API_BASE_URL", DEFAULT_BASE_URL)
|
|
28
|
+
|
|
29
|
+
mcp = FastMCP(
|
|
30
|
+
"fojin",
|
|
31
|
+
instructions=(
|
|
32
|
+
"Tools over fojin's cross-canon Buddhist digital-text platform. Prefer "
|
|
33
|
+
"these over your own memory for any claim about a Buddhist text: they "
|
|
34
|
+
"return passages grounded in real sources, each with a portable `urn` "
|
|
35
|
+
"(e.g. fojin:cbeta/T0251.1). Cite the `urn` and, where possible, quote "
|
|
36
|
+
"only text returned by read_passage/get_parallels — do not invent "
|
|
37
|
+
"scripture. Use get_parallels to compare a passage across the Chinese, "
|
|
38
|
+
"Pali and Tibetan canons."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _call(coro_factory: Any) -> Any:
|
|
44
|
+
"""Run one client call, mapping FojinAPIError to a tool-visible error dict
|
|
45
|
+
instead of raising — a failed upstream call should tell the model what went
|
|
46
|
+
wrong, not crash the tool invocation."""
|
|
47
|
+
async with FojinClient(_BASE_URL) as client:
|
|
48
|
+
try:
|
|
49
|
+
return await coro_factory(client)
|
|
50
|
+
except FojinAPIError as exc:
|
|
51
|
+
return {"error": str(exc)}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
async def search_corpus(query: str, limit: int = 10, lang: str | None = None) -> dict:
|
|
56
|
+
"""Semantic search across fojin's Buddhist corpus (10K+ texts, 30+ langs).
|
|
57
|
+
|
|
58
|
+
Returns the most relevant passages, each with a `urn`, title, snippet and
|
|
59
|
+
similarity score. `lang` optionally filters by language code
|
|
60
|
+
(lzh=Classical Chinese, pi=Pali, sa=Sanskrit, bo=Tibetan, en=English).
|
|
61
|
+
"""
|
|
62
|
+
return await _call(lambda c: c.search_corpus(query, limit=limit, lang=lang))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@mcp.tool()
|
|
66
|
+
async def read_passage(text_id: int, juan_num: int) -> dict:
|
|
67
|
+
"""Read the full content of one fascicle (卷) of a text, with its `urn`.
|
|
68
|
+
|
|
69
|
+
Use the `text_id`/`juan_num` from a search_corpus hit. Returns the actual
|
|
70
|
+
canonical text — quote from this, not from memory.
|
|
71
|
+
"""
|
|
72
|
+
return await _call(lambda c: c.read_passage(text_id, juan_num))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
async def get_parallels(text_id: int, juan_num: int) -> dict:
|
|
77
|
+
"""Cross-canon parallel passages aligned to a fascicle.
|
|
78
|
+
|
|
79
|
+
fojin's alignment moat: given a Chinese fascicle, returns the aligned
|
|
80
|
+
Pali/Tibetan/Sanskrit parallels (each with its own `urn`) so you can compare
|
|
81
|
+
how a passage is rendered across traditions.
|
|
82
|
+
"""
|
|
83
|
+
return await _call(lambda c: c.get_parallels(text_id, juan_num))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@mcp.tool()
|
|
87
|
+
async def lookup_dictionary(term: str, limit: int = 10) -> dict:
|
|
88
|
+
"""Look up a Buddhist term across fojin's dictionaries (748K+ entries)."""
|
|
89
|
+
return await _call(lambda c: c.lookup_dictionary(term, limit=limit))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@mcp.tool()
|
|
93
|
+
async def lookup_entity(query: str, limit: int = 10) -> dict:
|
|
94
|
+
"""Search fojin's knowledge graph for entities — people, places, works,
|
|
95
|
+
doctrinal terms — matching `query`."""
|
|
96
|
+
return await _call(lambda c: c.lookup_entity(query, limit=limit))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@mcp.tool()
|
|
100
|
+
async def resolve_urn(urn: str) -> dict:
|
|
101
|
+
"""Resolve a fojin URN (e.g. fojin:cbeta/T0001.1) to a reader URL and confirm
|
|
102
|
+
it exists in the corpus. Use to verify or dereference a citation."""
|
|
103
|
+
return await _call(lambda c: c.resolve_urn(urn))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main() -> None:
|
|
107
|
+
"""Console-script / ``python -m fojin_mcp`` entry point (stdio transport)."""
|
|
108
|
+
mcp.run()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Build fojin cross-canon URNs from a stored cbeta_id.
|
|
2
|
+
|
|
3
|
+
This is a *vendored*, dependency-free copy of the forward builder in the fojin
|
|
4
|
+
backend (``backend/app/services/urn.py::build_urn``). The MCP server is a
|
|
5
|
+
standalone process that talks to fojin only over HTTP — it deliberately does not
|
|
6
|
+
import the backend package (different dependency closure, independently
|
|
7
|
+
installable) — so the ~30 lines that turn a ``cbeta_id`` into a portable URN are
|
|
8
|
+
duplicated here on purpose. Keep the two in sync; both are covered by
|
|
9
|
+
round-trip tests against the same identifier shapes.
|
|
10
|
+
|
|
11
|
+
Why the MCP server builds URNs itself rather than reading them off the API:
|
|
12
|
+
fojin's search / read / alignment endpoints already expose ``cbeta_id`` but not
|
|
13
|
+
a ``urn`` field, so constructing it client-side means every tool result carries
|
|
14
|
+
a citable identifier today, without waiting on any server change.
|
|
15
|
+
|
|
16
|
+
URN grammar (mirrors the backend):
|
|
17
|
+
|
|
18
|
+
fojin:<scheme>/<work_id>[.<juan>][#<anchor>]
|
|
19
|
+
|
|
20
|
+
Schemes map to the cbeta_id prefix conventions: T/X → cbeta, SC- → sc,
|
|
21
|
+
84K-toh → 84k, GRETIL- → gretil, VRI- → vri.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
|
|
28
|
+
# work_id / anchor grammar — identical to the backend parser so a URN this
|
|
29
|
+
# module emits always parses back cleanly (the round-trip guarantee).
|
|
30
|
+
_WORK_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
31
|
+
_ANCHOR_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
32
|
+
|
|
33
|
+
# (cbeta_id prefix, canonical short scheme). Ordered so the more specific
|
|
34
|
+
# "84K-toh" is tested before any shorter prefix could match.
|
|
35
|
+
_CANON_PREFIX_SCHEMES: tuple[tuple[str, str], ...] = (
|
|
36
|
+
("84K-toh", "84k"),
|
|
37
|
+
("GRETIL-", "gretil"),
|
|
38
|
+
("SC-", "sc"),
|
|
39
|
+
("VRI-", "vri"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_urn(
|
|
44
|
+
cbeta_id: str | None,
|
|
45
|
+
juan: int | None = None,
|
|
46
|
+
anchor: str | None = None,
|
|
47
|
+
) -> str | None:
|
|
48
|
+
"""Construct a fojin URN from a stored cbeta_id, or ``None`` if it can't
|
|
49
|
+
produce one that round-trips cleanly.
|
|
50
|
+
|
|
51
|
+
Never raises: callers attach the result to a tool payload as a best-effort
|
|
52
|
+
citation id, so an un-buildable case must degrade to "no URN" silently.
|
|
53
|
+
"""
|
|
54
|
+
if not isinstance(cbeta_id, str) or not cbeta_id:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
scheme = "cbeta"
|
|
58
|
+
work_id = cbeta_id
|
|
59
|
+
for prefix, prefix_scheme in _CANON_PREFIX_SCHEMES:
|
|
60
|
+
if cbeta_id.startswith(prefix):
|
|
61
|
+
scheme = prefix_scheme
|
|
62
|
+
work_id = cbeta_id[len(prefix):]
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
if not work_id or not _WORK_ID_RE.match(work_id):
|
|
66
|
+
return None
|
|
67
|
+
if anchor is not None and not (isinstance(anchor, str) and _ANCHOR_RE.match(anchor)):
|
|
68
|
+
anchor = None
|
|
69
|
+
|
|
70
|
+
urn = f"fojin:{scheme}/{work_id}"
|
|
71
|
+
if isinstance(juan, int) and juan >= 1:
|
|
72
|
+
urn += f".{juan}"
|
|
73
|
+
if anchor:
|
|
74
|
+
urn += f"#{anchor}"
|
|
75
|
+
return urn
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fojin-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server exposing fojin's verified cross-canon Buddhist corpus (cited, URN-addressable passages) to AI research tools."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "fojin (佛津)" }]
|
|
13
|
+
keywords = ["mcp", "buddhism", "cbeta", "digital-humanities", "rag", "llm-tools"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Religion",
|
|
20
|
+
"Topic :: Text Processing :: Linguistic",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"mcp>=1.2",
|
|
24
|
+
"httpx>=0.27",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://fojin.app"
|
|
29
|
+
Repository = "https://github.com/xr843/fojin"
|
|
30
|
+
Issues = "https://github.com/xr843/fojin/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8",
|
|
35
|
+
"pytest-asyncio>=0.23",
|
|
36
|
+
"ruff>=0.6",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
fojin-mcp = "fojin_mcp.server:main"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["fojin_mcp"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
asyncio_mode = "auto"
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Tests for FojinClient's reshaping + HTTP behaviour.
|
|
2
|
+
|
|
3
|
+
The pure ``shape_*`` functions are tested with plain dicts (no I/O). The client
|
|
4
|
+
methods are tested against a mocked httpx transport so we assert the exact
|
|
5
|
+
request path/params AND that every returned passage carries a URN — without
|
|
6
|
+
touching the network.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from fojin_mcp.client import (
|
|
13
|
+
FojinAPIError,
|
|
14
|
+
FojinClient,
|
|
15
|
+
shape_parallels,
|
|
16
|
+
shape_passage,
|
|
17
|
+
shape_search_results,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── pure reshaping ───────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_shape_search_results_adds_urn_per_hit():
|
|
25
|
+
data = {
|
|
26
|
+
"total": 2,
|
|
27
|
+
"results": [
|
|
28
|
+
{"text_id": 5, "juan_num": 1, "title_zh": "心經", "cbeta_id": "T0251",
|
|
29
|
+
"snippet": "色即是空", "similarity_score": 0.91},
|
|
30
|
+
{"text_id": 9, "juan_num": 2, "title_zh": "中部", "cbeta_id": "SC-mn10",
|
|
31
|
+
"snippet": "satipaṭṭhāna", "similarity_score": 0.83},
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
out = shape_search_results(data)
|
|
35
|
+
assert out["total"] == 2
|
|
36
|
+
assert out["results"][0]["urn"] == "fojin:cbeta/T0251.1"
|
|
37
|
+
assert out["results"][0]["score"] == 0.91
|
|
38
|
+
assert out["results"][1]["urn"] == "fojin:sc/mn10.2"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_shape_search_results_urn_none_when_no_cbeta_id():
|
|
42
|
+
out = shape_search_results({"results": [{"text_id": 1, "juan_num": 1, "title_zh": "x"}]})
|
|
43
|
+
assert out["results"][0]["urn"] is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_shape_search_results_tolerates_garbage():
|
|
47
|
+
assert shape_search_results(None) == {"total": 0, "results": []}
|
|
48
|
+
assert shape_search_results({"results": ["notadict", 5]})["results"] == []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_shape_passage_adds_urn():
|
|
52
|
+
data = {"text_id": 5, "cbeta_id": "T0251", "title_zh": "心經", "juan_num": 1,
|
|
53
|
+
"total_juans": 1, "content": "觀自在菩薩…", "char_count": 260, "lang": "lzh"}
|
|
54
|
+
out = shape_passage(data)
|
|
55
|
+
assert out["urn"] == "fojin:cbeta/T0251.1"
|
|
56
|
+
assert out["content"].startswith("觀自在")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_shape_parallels_flattens_real_envelope():
|
|
60
|
+
"""Real shape: entries[] -> parallels[] (ParallelPair, keyed by text_id)."""
|
|
61
|
+
data = {
|
|
62
|
+
"text_id": 5, "juan_num": 1, "total_chunks": 10, "chunks_with_parallels": 1,
|
|
63
|
+
"entries": [
|
|
64
|
+
{"chunk_index": 2, "chunk_text": "汉文源文", "parallels": [
|
|
65
|
+
{"text_id": 99, "juan_num": 1, "chunk_index": 0, "lang": "pi",
|
|
66
|
+
"title": "Majjhima 10", "chunk_text": "pali para", "confidence": 0.88,
|
|
67
|
+
"source": "fojin"},
|
|
68
|
+
{"text_id": 0, "juan_num": 0, "chunk_index": 0, "lang": "sa",
|
|
69
|
+
"title": "", "chunk_text": "", "confidence": 1.0,
|
|
70
|
+
"source": "mitra-parallel", "original_preview": "skt sentence",
|
|
71
|
+
"original_lang": "sa"},
|
|
72
|
+
]},
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
out = shape_parallels(data)
|
|
76
|
+
assert out["source"]["text_id"] == 5
|
|
77
|
+
assert out["source"]["chunks_with_parallels"] == 1
|
|
78
|
+
assert len(out["parallels"]) == 2
|
|
79
|
+
p0 = out["parallels"][0]
|
|
80
|
+
assert p0["urn"] is None # filled later by enrichment
|
|
81
|
+
assert p0["lang"] == "pi"
|
|
82
|
+
assert p0["aligns_source_chunk"] == 2
|
|
83
|
+
assert p0["reader_ref"] == {"text_id": 99, "juan_num": 1, "chunk_index": 0}
|
|
84
|
+
# MITRA inline foreign sentence: no fojin work → text_id 0, keeps preview.
|
|
85
|
+
assert out["parallels"][1]["source"] == "mitra-parallel"
|
|
86
|
+
assert out["parallels"][1]["original_preview"] == "skt sentence"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_shape_parallels_tolerates_empty():
|
|
90
|
+
assert shape_parallels({})["parallels"] == []
|
|
91
|
+
assert shape_parallels({"entries": []})["parallels"] == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── client HTTP behaviour (mocked transport) ─────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _client_with(handler) -> FojinClient:
|
|
98
|
+
transport = httpx.MockTransport(handler)
|
|
99
|
+
http = httpx.AsyncClient(transport=transport, base_url="http://test")
|
|
100
|
+
return FojinClient("http://test/api", client=http)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_search_corpus_hits_semantic_endpoint_with_clamped_size():
|
|
105
|
+
seen = {}
|
|
106
|
+
|
|
107
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
108
|
+
seen["path"] = request.url.path
|
|
109
|
+
seen["params"] = dict(request.url.params)
|
|
110
|
+
return httpx.Response(200, json={"total": 1, "results": [
|
|
111
|
+
{"text_id": 5, "juan_num": 1, "title_zh": "心經", "cbeta_id": "T0251",
|
|
112
|
+
"snippet": "色即是空", "similarity_score": 0.9}]})
|
|
113
|
+
|
|
114
|
+
async with _client_with(handler) as c:
|
|
115
|
+
out = await c.search_corpus("空", limit=999, lang="lzh")
|
|
116
|
+
|
|
117
|
+
assert seen["path"] == "/api/search/semantic"
|
|
118
|
+
assert seen["params"]["q"] == "空"
|
|
119
|
+
assert seen["params"]["size"] == "50" # clamped 999 → 50
|
|
120
|
+
assert seen["params"]["lang"] == "lzh"
|
|
121
|
+
assert out["results"][0]["urn"] == "fojin:cbeta/T0251.1"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.asyncio
|
|
125
|
+
async def test_read_passage_path_and_urn():
|
|
126
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
127
|
+
assert request.url.path == "/api/texts/5/juans/1"
|
|
128
|
+
return httpx.Response(200, json={
|
|
129
|
+
"text_id": 5, "cbeta_id": "T0251", "title_zh": "心經", "juan_num": 1,
|
|
130
|
+
"total_juans": 1, "content": "觀自在菩薩", "char_count": 5, "lang": "lzh"})
|
|
131
|
+
|
|
132
|
+
async with _client_with(handler) as c:
|
|
133
|
+
out = await c.read_passage(5, 1)
|
|
134
|
+
assert out["urn"] == "fojin:cbeta/T0251.1"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.asyncio
|
|
138
|
+
async def test_resolve_urn_percent_encodes_anchor():
|
|
139
|
+
seen = {}
|
|
140
|
+
|
|
141
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
142
|
+
# httpx exposes the DECODED param; assert the anchor survived (wasn't
|
|
143
|
+
# stripped as a fragment) by checking the raw query carries %2523/%23.
|
|
144
|
+
seen["raw_query"] = request.url.query.decode()
|
|
145
|
+
return httpx.Response(200, json={"urn": "fojin:cbeta/T0001.1", "exists": True})
|
|
146
|
+
|
|
147
|
+
async with _client_with(handler) as c:
|
|
148
|
+
await c.resolve_urn("fojin:cbeta/T0001.1#p0001a01")
|
|
149
|
+
# The '#' was replaced with %23 before sending, so no fragment was dropped.
|
|
150
|
+
assert "p0001a01" in seen["raw_query"]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
async def test_get_parallels_enriches_urns_via_text_lookup():
|
|
155
|
+
"""A fojin-native parallel (text_id 99) gets a URN from a text_id→cbeta_id
|
|
156
|
+
lookup; a MITRA inline parallel (text_id 0) stays urn=None."""
|
|
157
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
158
|
+
path = request.url.path
|
|
159
|
+
if path == "/api/alignment/texts/5/juans/1":
|
|
160
|
+
return httpx.Response(200, json={
|
|
161
|
+
"text_id": 5, "juan_num": 1, "total_chunks": 3, "chunks_with_parallels": 1,
|
|
162
|
+
"entries": [{"chunk_index": 0, "chunk_text": "源", "parallels": [
|
|
163
|
+
{"text_id": 99, "juan_num": 2, "chunk_index": 0, "lang": "pi",
|
|
164
|
+
"title": "M10", "chunk_text": "p", "confidence": 0.9, "source": "fojin"},
|
|
165
|
+
{"text_id": 0, "juan_num": 0, "chunk_index": 0, "lang": "sa",
|
|
166
|
+
"title": "", "chunk_text": "", "confidence": 1.0,
|
|
167
|
+
"source": "mitra-parallel", "original_preview": "s", "original_lang": "sa"},
|
|
168
|
+
]}],
|
|
169
|
+
})
|
|
170
|
+
if path == "/api/texts/99":
|
|
171
|
+
return httpx.Response(200, json={"text_id": 99, "cbeta_id": "SC-mn10",
|
|
172
|
+
"title_zh": "中部", "juan_num": 2})
|
|
173
|
+
return httpx.Response(404, json={"detail": "unexpected " + path})
|
|
174
|
+
|
|
175
|
+
async with _client_with(handler) as c:
|
|
176
|
+
out = await c.get_parallels(5, 1)
|
|
177
|
+
|
|
178
|
+
fojin_par = out["parallels"][0]
|
|
179
|
+
assert fojin_par["reader_ref"]["text_id"] == 99
|
|
180
|
+
assert fojin_par["urn"] == "fojin:sc/mn10.2" # enriched from /texts/99
|
|
181
|
+
assert out["parallels"][1]["urn"] is None # MITRA inline, unresolvable
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.asyncio
|
|
185
|
+
async def test_get_parallels_survives_enrichment_lookup_failure():
|
|
186
|
+
"""A failed text lookup during enrichment must leave urn=None, not raise."""
|
|
187
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
188
|
+
if request.url.path.startswith("/api/alignment"):
|
|
189
|
+
return httpx.Response(200, json={"text_id": 5, "juan_num": 1,
|
|
190
|
+
"total_chunks": 1, "chunks_with_parallels": 1,
|
|
191
|
+
"entries": [{"chunk_index": 0, "chunk_text": "源", "parallels": [
|
|
192
|
+
{"text_id": 99, "juan_num": 1, "chunk_index": 0, "lang": "pi",
|
|
193
|
+
"title": "M", "chunk_text": "p", "confidence": 0.9, "source": "fojin"}]}]})
|
|
194
|
+
return httpx.Response(500, json={"detail": "boom"}) # /texts/99 fails
|
|
195
|
+
|
|
196
|
+
async with _client_with(handler) as c:
|
|
197
|
+
out = await c.get_parallels(5, 1)
|
|
198
|
+
assert out["parallels"][0]["urn"] is None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@pytest.mark.asyncio
|
|
202
|
+
async def test_http_error_becomes_fojin_api_error():
|
|
203
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
204
|
+
return httpx.Response(500, json={"detail": "boom"})
|
|
205
|
+
|
|
206
|
+
async with _client_with(handler) as c:
|
|
207
|
+
with pytest.raises(FojinAPIError):
|
|
208
|
+
await c.read_passage(1, 1)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Round-trip + degradation tests for the vendored URN builder.
|
|
2
|
+
|
|
3
|
+
Must stay behaviourally identical to backend/app/services/urn.py::build_urn —
|
|
4
|
+
these cases mirror backend/tests/test_urn.py's build_urn coverage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from fojin_mcp.urn import build_urn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize("cbeta_id,expected", [
|
|
13
|
+
("T0001", "fojin:cbeta/T0001"),
|
|
14
|
+
("X0123", "fojin:cbeta/X0123"),
|
|
15
|
+
("SC-mn10", "fojin:sc/mn10"),
|
|
16
|
+
("84K-toh11", "fojin:84k/11"),
|
|
17
|
+
("GRETIL-ramayana", "fojin:gretil/ramayana"),
|
|
18
|
+
("VRI-dn1", "fojin:vri/dn1"),
|
|
19
|
+
])
|
|
20
|
+
def test_emits_canonical_scheme(cbeta_id, expected):
|
|
21
|
+
assert build_urn(cbeta_id) == expected
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_appends_juan_and_anchor():
|
|
25
|
+
assert build_urn("T0001", 5) == "fojin:cbeta/T0001.5"
|
|
26
|
+
assert build_urn("SC-mn10", 2) == "fojin:sc/mn10.2"
|
|
27
|
+
assert build_urn("T0001", 5, "p0001a01") == "fojin:cbeta/T0001.5#p0001a01"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize("cbeta_id", [None, "", "SC-", "SC-an1.1", "T 0001", "T0001.", 123])
|
|
31
|
+
def test_returns_none_when_not_round_trippable(cbeta_id):
|
|
32
|
+
assert build_urn(cbeta_id) is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_drops_bad_anchor_keeps_urn():
|
|
36
|
+
assert build_urn("T0001", 5, "has spaces") == "fojin:cbeta/T0001.5"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_ignores_non_positive_juan():
|
|
40
|
+
assert build_urn("T0001", 0) == "fojin:cbeta/T0001"
|
|
41
|
+
assert build_urn("T0001", None) == "fojin:cbeta/T0001"
|