@gobi-starweaver/ws-cli 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/{chunks/chunk-S7EGXUII.js → dist/chunks/chunk-B2GIIUZ7.js} +2 -2
  2. package/{chunks/chunk-O2SA6W2I.js → dist/chunks/chunk-EMOHUSKW.js} +820 -456
  3. package/{chunks/chunk-BUBNMVFA.js → dist/chunks/chunk-PJQH2TDX.js} +2 -2
  4. package/{chunks/devtools-V5WI5QLO.js → dist/chunks/devtools-6ASGFZ3B.js} +33 -33
  5. package/{cli.js → dist/cli.js} +3988 -1199
  6. package/{mcp → dist/mcp}/servers/doc-toolchain-server.js +8 -8
  7. package/{mcp → dist/mcp}/servers/ws-vision-ocr-server.js +2 -2
  8. package/dist/vendor/hj_corpus/hj_corpus_mcp_server.py +436 -0
  9. package/dist/vendor/hj_corpus/hj_index_search.py +359 -0
  10. package/package.json +31 -11
  11. package/README-en.md +0 -52
  12. /package/{assets → dist/assets}/assembly-orders/README.md +0 -0
  13. /package/{assets → dist/assets}/asset-catalog/index.json +0 -0
  14. /package/{assets → dist/assets}/asset-catalog/snapshots/catalog-2026-08-15.json +0 -0
  15. /package/{assets → dist/assets}/card-templates/README.md +0 -0
  16. /package/{assets → dist/assets}/card-templates/collator.card.json +0 -0
  17. /package/{assets → dist/assets}/card-templates/deep-reader.card.json +0 -0
  18. /package/{assets → dist/assets}/card-templates/grant-editor.card.json +0 -0
  19. /package/{assets → dist/assets}/card-templates/grant-writer.card.json +0 -0
  20. /package/{assets → dist/assets}/card-templates/paper-review-rechecker.card.json +0 -0
  21. /package/{assets → dist/assets}/card-templates/paper-reviewer.card.json +0 -0
  22. /package/{assets → dist/assets}/card-templates/translation-reviewer.card.json +0 -0
  23. /package/{assets → dist/assets}/card-templates/translator.card.json +0 -0
  24. /package/{assets → dist/assets}/card-templates/visual-general.card.json +0 -0
  25. /package/{assets → dist/assets}/card-templates/writing-author.card.json +0 -0
  26. /package/{assets → dist/assets}/card-templates/writing-editor.card.json +0 -0
  27. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/344/270/223/345/277/203/351/251/276/351/251/266/350/257/264/346/230/216_/345/206/231/344/275/234/347/273/204.md" +0 -0
  28. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/344/270/223/345/277/203/351/251/276/351/251/266/350/257/264/346/230/216_/345/256/241/347/250/277/347/273/204.md" +0 -0
  29. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/344/270/223/345/277/203/351/251/276/351/251/266/350/257/264/346/230/216_/347/224/263/346/212/245/347/273/204.md" +0 -0
  30. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/344/270/223/345/277/203/351/251/276/351/251/266/350/257/264/346/230/216_/347/277/273/350/257/221/347/273/204.md" +0 -0
  31. /package/{assets → dist/assets}/card-templates//346/234/233/350/210/222/350/275/254/345/221/210/345/275/242/346/200/201/350/247/204/350/214/203.md" +0 -0
  32. /package/{assets → dist/assets}/expressions/ascii-art.json +0 -0
  33. /package/{assets → dist/assets}/expressions/emoji-full.json +0 -0
  34. /package/{assets → dist/assets}/expressions/emoji.json +0 -0
  35. /package/{bundled → dist/bundled}/plan/SKILL.md +0 -0
  36. /package/{templates → dist/templates}/prompts/init_command.md.ejs +0 -0
@@ -1,23 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  killProcessTree
4
- } from "../../chunks/chunk-S7EGXUII.js";
4
+ } from "../../chunks/chunk-B2GIIUZ7.js";
5
5
  import {
6
6
  __name,
7
7
  init_esbuild_shims
8
- } from "../../chunks/chunk-BUBNMVFA.js";
8
+ } from "../../chunks/chunk-PJQH2TDX.js";
9
9
 
10
- // packages/core/src/mcp/servers/doc-toolchain-server.ts
10
+ // ../core/src/mcp/servers/doc-toolchain-server.ts
11
11
  init_esbuild_shims();
12
12
  import { createInterface } from "node:readline";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import * as fs2 from "node:fs";
15
15
  import * as path2 from "node:path";
16
16
 
17
- // packages/core/src/mcp/servers/doc-toolchain/ooxml.ts
17
+ // ../core/src/mcp/servers/doc-toolchain/ooxml.ts
18
18
  init_esbuild_shims();
19
19
 
20
- // packages/core/src/mcp/servers/doc-toolchain/zip.ts
20
+ // ../core/src/mcp/servers/doc-toolchain/zip.ts
21
21
  init_esbuild_shims();
22
22
  import { deflateRawSync, inflateRawSync } from "node:zlib";
23
23
  var EOCD_SIGNATURE = 101010256;
@@ -181,7 +181,7 @@ function writeZip(inputs) {
181
181
  }
182
182
  __name(writeZip, "writeZip");
183
183
 
184
- // packages/core/src/mcp/servers/doc-toolchain/ooxml.ts
184
+ // ../core/src/mcp/servers/doc-toolchain/ooxml.ts
185
185
  var XML_DECLARATION = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`;
186
186
  var OoxmlFormatError = class extends Error {
187
187
  static {
@@ -659,7 +659,7 @@ function editDocx(buffer, ops) {
659
659
  }
660
660
  __name(editDocx, "editDocx");
661
661
 
662
- // packages/core/src/mcp/servers/doc-toolchain/convert.ts
662
+ // ../core/src/mcp/servers/doc-toolchain/convert.ts
663
663
  init_esbuild_shims();
664
664
  import { spawn } from "node:child_process";
665
665
  import * as fs from "node:fs";
@@ -1190,7 +1190,7 @@ async function probeEnvironment(deps = {}) {
1190
1190
  }
1191
1191
  __name(probeEnvironment, "probeEnvironment");
1192
1192
 
1193
- // packages/core/src/mcp/servers/doc-toolchain-server.ts
1193
+ // ../core/src/mcp/servers/doc-toolchain-server.ts
1194
1194
  var DOC_TOOLCHAIN_SERVER_NAME = "doc_toolchain";
1195
1195
  var DOC_TOOLCHAIN_TOOL_PREFIX = "mcp__doc_toolchain__";
1196
1196
  var EDIT_OPS = ["replace_paragraph_text", "insert_paragraph", "delete_paragraph", "append_paragraph"];
@@ -13,8 +13,8 @@ import {
13
13
  releaseVisionImageRef,
14
14
  resolveVisionGatewayTarget,
15
15
  runWsVisionMcpServer
16
- } from "../../chunks/chunk-O2SA6W2I.js";
17
- import "../../chunks/chunk-BUBNMVFA.js";
16
+ } from "../../chunks/chunk-EMOHUSKW.js";
17
+ import "../../chunks/chunk-PJQH2TDX.js";
18
18
  export {
19
19
  VISION_TOOL_DEFINITIONS,
20
20
  WS_VISION_BRIDGE_TIMEOUT_MS,
@@ -0,0 +1,436 @@
1
+ """中古史学术IDE · 汉籍语料索引 MCP server(插件 v0.1 · 北辰 CLI vendored 副本)
2
+
3
+ ★ vendored 自 mvp_backend/hj_corpus_mcp_server.py(检索核心单源不复制原则的例外=同源两份):
4
+ 改库/改检索逻辑一律先改主 IDE 源 mvp_backend/,本副本跟版同步;两件的差异只许在
5
+ ①本段 vendored 注记 ②工具 annotations(readOnlyHint·⑥通用免问机制)
6
+ ③B3 远程模式分支(方案 v0.8 §三「库在线」指定分叉:本地库不可用 → 四器改打北辰网关
7
+ /hj/* 端点·订阅令牌鉴权·读 ~/.ws/auth.json;本地模式与主源逐字一致)。
8
+ 依赖:python 3.10+ + `pip install mcp`(FastMCP);库路径经 HJ_CORPUS_DB 环境变量注入
9
+ (CLI settings 默认注入已配好;无库时 CLI 不注入本服务·隐身)。
10
+
11
+ 汉籍索引库封装进 agent,用插件组装,agent 用 MCP 协议访问。
12
+
13
+ 定位:hj_4_jt_fts.sqlite(4.86GB trigram FTS5 全库,66876 篇,含《唐六典》全 30 卷)的
14
+ 只读检索 MCP 服务(stdio JSON-RPC)。检索核心复用 hj_index_search.py(单源不复制)。
15
+
16
+ 本服务修缺三件(agent 多词组合 0 命中、误报「本地无《唐六典》」):
17
+ 1. 多词组合检索(hj_search mode=all_terms)——旧接口把整串归一化成连续短语
18
+ (normalize_with_map 连空格一并吞掉),多词查询必然 0 命中;
19
+ 2. 书名/卷名定位(hj_find_book)——旧接口没有「库里有没有某书、有哪些卷」的问法;
20
+ 3. 命中总数如实报(total_hits)——旧接口 hit_count=截断后条数(1061 条真实命中报 10),
21
+ 配 offset 分页,不静默截断(A-26 量具之报)。
22
+ 另补 hj_get_article 按篇取全文(分片,防一次取爆)。
23
+
24
+ trigram 索引固有边界(如实报,不掩饰):规范化后少于 3 字的词进不了 trigram 倒排,
25
+ all_terms 模式下短词以 LIKE 在命中集内过滤(原文若被标点隔断则匹配不到,warning 注明)。
26
+
27
+ 安全边界:file:?mode=ro + PRAGMA query_only=ON,只读;绝不写库。
28
+ 证据政策每返回必带:命中只作出处候选与上下文依据,不作页码依据(page_basis=false)。
29
+
30
+ 运行:python hj_corpus_mcp_server.py [--db <path>] # stdio MCP server
31
+ 直测:python hj_corpus_mcp_server.py --selftest # 不经协议直测 SQL 层
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import json
38
+ import os
39
+ import sqlite3
40
+ import sys
41
+ import urllib.error
42
+ import urllib.request
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ BACKEND_DIR = Path(__file__).resolve().parent
47
+ sys.path.insert(0, str(BACKEND_DIR))
48
+
49
+ import hj_index_search as hj # 检索核心单源:normalize/fts_phrase/open/context/candidate/policy
50
+
51
+ DB_PATH = Path(os.environ.get("HJ_CORPUS_DB") or hj.DEFAULT_DB)
52
+
53
+ # ===== B3 远程模式(方案 v0.8 §三·库在线)=====
54
+ # 本地库不可用(HJ_CORPUS_DB 未设或指向文件不存在)→ 四器改打北辰网关 /hj/* 端点
55
+ # (验卡同款:Authorization Bearer 卡密 + x-polaris-device 设备头·读 ~/.ws/auth.json)。
56
+ HJ_REMOTE_BASE_URL = os.environ.get(
57
+ "HJ_REMOTE_BASE_URL", "https://api.gobi-starweaver.cn:8443").rstrip("/")
58
+ _HJ_REMOTE_TIMEOUT = int(os.environ.get("HJ_REMOTE_TIMEOUT", "60"))
59
+
60
+
61
+ def _local_db_available() -> bool:
62
+ try:
63
+ return DB_PATH.exists()
64
+ except OSError:
65
+ return False
66
+
67
+
68
+ def _read_auth() -> tuple[str, str]:
69
+ """读 ~/.ws/auth.json → (card_token, device_id);无文件/损坏/缺字段 → ("", "")。"""
70
+ try:
71
+ raw = Path.home().joinpath(".ws", "auth.json").read_text(encoding="utf-8")
72
+ data = json.loads(raw)
73
+ return str(data.get("card_token") or ""), str(data.get("device_id") or "")
74
+ except Exception:
75
+ return "", ""
76
+
77
+
78
+ def _remote_call(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
79
+ """POST 网关 /hj/<endpoint>;回包 dict 原样(与本地 do_* 同构契约)。
80
+ 未登录/网络异常 → error dict 如实报(不 raise·与 do_* 的错误随包契约一致)。"""
81
+ token, device = _read_auth()
82
+ if not token or not device:
83
+ return {"error": "远程汉籍库需要登录态:未读到有效登录(~/.ws/auth.json),"
84
+ "请先在 CLI 内 /login 后重试。"}
85
+ req = urllib.request.Request(
86
+ f"{HJ_REMOTE_BASE_URL}/hj/{endpoint}",
87
+ data=json.dumps(payload).encode("utf-8"),
88
+ headers={
89
+ "Content-Type": "application/json",
90
+ "Authorization": f"Bearer {token}",
91
+ "x-polaris-device": device,
92
+ },
93
+ method="POST",
94
+ )
95
+ try:
96
+ with urllib.request.urlopen(req, timeout=_HJ_REMOTE_TIMEOUT) as resp:
97
+ return json.loads(resp.read().decode("utf-8"))
98
+ except urllib.error.HTTPError as e:
99
+ try:
100
+ return json.loads(e.read().decode("utf-8"))
101
+ except Exception:
102
+ return {"error": f"网关回 HTTP {e.code}(远程汉籍库暂不可用)。"}
103
+ except Exception as e:
104
+ return {"error": f"远程汉籍库连接失败:{type(e).__name__}(请检查网络后重试)。"}
105
+
106
+ ARTICLE_SLICE_MAX = 8000
107
+ SEARCH_LIMIT_MAX = 50
108
+
109
+
110
+ def _connect() -> sqlite3.Connection:
111
+ return hj.open_hj_index(DB_PATH)
112
+
113
+
114
+ def _policy() -> dict[str, Any]:
115
+ return dict(hj.EVIDENCE_POLICY)
116
+
117
+
118
+ def _terms_of(query: str) -> tuple[list[str], list[str], list[str]]:
119
+ """按空白拆词并归一化;返回 (全部词原貌, 可进 trigram 的词≥3字, 短词<3字)。"""
120
+ raw_terms = [t for t in hj.compact_query(query).split(" ") if t]
121
+ long_terms: list[str] = []
122
+ short_terms: list[str] = []
123
+ for t in raw_terms:
124
+ n = hj.normalize_query(t)
125
+ if len(n) >= 3:
126
+ long_terms.append(n)
127
+ elif n:
128
+ short_terms.append(t) # 短词留原貌走 LIKE
129
+ return raw_terms, long_terms, short_terms
130
+
131
+
132
+ def do_find_book(name_like: str, limit: int = 50) -> dict[str, Any]:
133
+ name_like = hj.compact_query(name_like)
134
+ if not name_like:
135
+ return {"error": "name_like 不能为空。"}
136
+ limit = max(1, min(int(limit), 200))
137
+ if not _local_db_available():
138
+ return _remote_call("find_book", {"name_like": name_like, "limit": limit})
139
+ conn = _connect()
140
+ try:
141
+ pattern = f"%{name_like}%"
142
+ total = conn.execute(
143
+ "SELECT COUNT(*) FROM docs WHERE title LIKE ?", (pattern,)
144
+ ).fetchone()[0]
145
+ rows = conn.execute(
146
+ "SELECT docid, title, book_base, article_no, char_count FROM docs "
147
+ "WHERE title LIKE ? ORDER BY book_base, article_no LIMIT ?",
148
+ (pattern, limit),
149
+ ).fetchall()
150
+ finally:
151
+ conn.close()
152
+ return {
153
+ "name_like": name_like,
154
+ "total_matches": total,
155
+ "returned": len(rows),
156
+ "note": "按篇名(title,形如「唐六典·卷五·尚书兵部」)模糊匹配;total_matches 为全库真实总数。",
157
+ "items": [
158
+ {"docid": r["docid"], "title": r["title"], "book_base": r["book_base"],
159
+ "article_no": r["article_no"], "char_count": r["char_count"]}
160
+ for r in rows
161
+ ],
162
+ "evidence_policy": _policy(),
163
+ }
164
+
165
+
166
+ def do_search(query: str, mode: str = "phrase", title_like: str = "",
167
+ limit: int = 10, offset: int = 0, context_chars: int = 80) -> dict[str, Any]:
168
+ raw_query = hj.compact_query(query)
169
+ if not raw_query:
170
+ return {"error": "query 不能为空。"}
171
+ mode = (mode or "phrase").strip().lower()
172
+ if mode not in ("phrase", "all_terms"):
173
+ return {"error": f"mode 须为 phrase 或 all_terms,收到:{mode}"}
174
+ limit = max(1, min(int(limit), SEARCH_LIMIT_MAX))
175
+ offset = max(0, int(offset))
176
+ context_chars = max(20, min(int(context_chars), 400))
177
+ if not _local_db_available():
178
+ return _remote_call("search", {
179
+ "query": raw_query, "mode": mode, "title_like": title_like,
180
+ "limit": limit, "offset": offset, "context_chars": context_chars,
181
+ })
182
+ warnings: list[str] = []
183
+
184
+ where: list[str] = []
185
+ params: list[object] = []
186
+ from_clause = "FROM docs_fts JOIN docs AS d ON d.docid = docs_fts.rowid"
187
+ rank_select = "bm25(docs_fts) AS rank"
188
+ order_by = "ORDER BY rank"
189
+ like_filter_terms: list[str] = []
190
+
191
+ if mode == "phrase":
192
+ normalized = hj.normalize_query(raw_query)
193
+ if len(normalized) < 3:
194
+ return {"error": "短语经规范化后不足 3 字,trigram 索引无法命中;"
195
+ "请加长短语,或用 all_terms 模式与较长词组合。"}
196
+ where.append("docs_fts MATCH ?")
197
+ params.append(hj.fts_phrase(normalized))
198
+ primary_terms = [raw_query]
199
+ else: # all_terms
200
+ raw_terms, long_terms, short_terms = _terms_of(raw_query)
201
+ if not raw_terms:
202
+ return {"error": "query 不能为空。"}
203
+ if not long_terms:
204
+ return {"error": "all_terms 模式至少需要一个规范化后 ≥3 字的词"
205
+ "(trigram 索引边界);全部短词请改用更长的词或加长短语。"}
206
+ where.append("docs_fts MATCH ?")
207
+ params.append(" AND ".join(hj.fts_phrase(t) for t in long_terms))
208
+ like_filter_terms = short_terms
209
+ for st in short_terms:
210
+ where.append("d.body LIKE ?")
211
+ params.append(f"%{st}%")
212
+ if short_terms:
213
+ warnings.append(
214
+ f"短词 {short_terms} 以 LIKE 在命中集内过滤(不足 3 字进不了 trigram 倒排);"
215
+ "原文中若被标点/空白隔断则匹配不到。"
216
+ )
217
+ primary_terms = raw_terms
218
+
219
+ if title_like:
220
+ where.append("d.title LIKE ?")
221
+ params.append(f"%{hj.compact_query(title_like)}%")
222
+
223
+ where_sql = " AND ".join(where)
224
+ conn = _connect()
225
+ try:
226
+ total_hits = conn.execute(
227
+ f"SELECT COUNT(*) {from_clause} WHERE {where_sql}", params
228
+ ).fetchone()[0]
229
+ rows = conn.execute(
230
+ f"SELECT d.docid, d.rel_path, d.book_base, d.article_no, d.title, d.source, "
231
+ f"d.body, d.char_count, {rank_select} {from_clause} WHERE {where_sql} "
232
+ f"{order_by} LIMIT ? OFFSET ?",
233
+ [*params, limit, offset],
234
+ ).fetchall()
235
+ finally:
236
+ conn.close()
237
+
238
+ locate_term = primary_terms[0]
239
+ normalized_locate = hj.normalize_query(locate_term)
240
+ candidates = [
241
+ hj.row_to_candidate(row, locate_term, normalized_locate, context_chars)
242
+ for row in rows
243
+ ]
244
+ if total_hits > offset + len(candidates):
245
+ warnings.append(
246
+ f"total_hits={total_hits},本次仅返回第 {offset + 1}—{offset + len(candidates)} 条"
247
+ f"(bm25 相关度序);其余经 offset 翻页取。"
248
+ )
249
+ return {
250
+ "query": raw_query,
251
+ "mode": mode,
252
+ "title_like": title_like or None,
253
+ "total_hits": total_hits,
254
+ "offset": offset,
255
+ "returned": len(candidates),
256
+ "warnings": warnings,
257
+ "results": candidates,
258
+ "evidence_policy": _policy(),
259
+ }
260
+
261
+
262
+ def do_get_article(docid: int = 0, title: str = "",
263
+ body_offset: int = 0, length: int = 4000) -> dict[str, Any]:
264
+ if not docid and not title:
265
+ return {"error": "docid 与 title 至少给一个(title 须精确篇名,可先经 hj_find_book 取得)。"}
266
+ body_offset = max(0, int(body_offset))
267
+ length = max(200, min(int(length), ARTICLE_SLICE_MAX))
268
+ if not _local_db_available():
269
+ return _remote_call("get_article", {
270
+ "docid": docid, "title": title,
271
+ "body_offset": body_offset, "length": length,
272
+ })
273
+ conn = _connect()
274
+ try:
275
+ if docid:
276
+ row = conn.execute(
277
+ "SELECT docid, rel_path, book_base, article_no, title, source, body, char_count "
278
+ "FROM docs WHERE docid = ?", (int(docid),)
279
+ ).fetchone()
280
+ else:
281
+ exact = hj.compact_query(title)
282
+ row = conn.execute(
283
+ "SELECT docid, rel_path, book_base, article_no, title, source, body, char_count "
284
+ "FROM docs WHERE title = ?", (exact,)
285
+ ).fetchone()
286
+ if row is None:
287
+ cands = conn.execute(
288
+ "SELECT docid, title FROM docs WHERE title LIKE ? LIMIT 8",
289
+ (f"%{exact}%",),
290
+ ).fetchall()
291
+ return {"error": f"未找到精确篇名「{exact}」。",
292
+ "near_titles": [{"docid": c["docid"], "title": c["title"]} for c in cands]}
293
+ finally:
294
+ conn.close()
295
+ if row is None:
296
+ return {"error": f"docid={docid} 不存在。"}
297
+ body = row["body"] or ""
298
+ piece = body[body_offset: body_offset + length]
299
+ return {
300
+ "docid": row["docid"],
301
+ "title": row["title"],
302
+ "book_base": row["book_base"],
303
+ "article_no": row["article_no"],
304
+ "source": row["source"],
305
+ "body_total_chars": len(body),
306
+ "body_offset": body_offset,
307
+ "returned_chars": len(piece),
308
+ "has_more": body_offset + len(piece) < len(body),
309
+ "note": "全文按片返回(单片上限 8000 字);has_more=true 时调大 body_offset 继续取。",
310
+ "body": piece,
311
+ "evidence_policy": _policy(),
312
+ }
313
+
314
+
315
+ def do_stats() -> dict[str, Any]:
316
+ if not _local_db_available():
317
+ return _remote_call("stats", {})
318
+ info = hj.inspect_hj_index(DB_PATH)
319
+ info["note"] = ("汉籍语料索引(trigram FTS5)只读服务;检索命中只作出处候选与上下文依据,"
320
+ "不作页码依据。")
321
+ return info
322
+
323
+
324
+ # ---- MCP server(FastMCP,stdio)----
325
+
326
+ def build_mcp_app():
327
+ from mcp.server.fastmcp import FastMCP
328
+ from mcp.types import ToolAnnotations
329
+
330
+ # 只读自声明(⑥通用免问机制):本服务确实只读(file:?mode=ro + PRAGMA query_only=ON·
331
+ # 绝不写库),如实挂 readOnlyHint——宿主 CLI 权限层凭它对 mcp 工具免 ask(A-26 如实消费)。
332
+ # 注意:hj_stats 同为只读,一并挂(handoff 旧稿列 3 工具为笔误,4 个全只读)。
333
+ READ_ONLY = ToolAnnotations(readOnlyHint=True)
334
+
335
+ app = FastMCP(
336
+ "hj-corpus",
337
+ instructions=(
338
+ "汉籍语料索引只读检索服务(含两《唐书》《唐六典》《资治通鉴》等;"
339
+ "实际篇数以 hj_stats 返回为准——封装核心库已剔清明清以后小说戏曲簇,篇数少于开发全量库)。"
340
+ "用法:hj_find_book 先定位书/卷;hj_search 检索(phrase=连续短语、"
341
+ "all_terms=多词组合 AND,title_like 可限定在某书内);hj_get_article 按篇取全文。"
342
+ "铁律:命中只作出处候选与上下文依据,不作页码依据(page_basis=false);"
343
+ "版本/卷次细目/页码须按拟引用版本另行人工核定。"
344
+ "边界:本库若为分发核心版,已剔 C07/C08/非 allowlist 的 C09 明清小说秘史簇"
345
+ "与 D02 文学戏曲辅助簇及大型元曲杂剧;hj 未命中只能说核心库未命中,不等于汉籍无该出处。"
346
+ ),
347
+ )
348
+
349
+ @app.tool(annotations=READ_ONLY)
350
+ def hj_find_book(name_like: str, limit: int = 50) -> dict:
351
+ """书名/卷名定位:按篇名模糊匹配(如「唐六典」可列出全部 30 卷),返回全库真实总数
352
+ total_matches 与篇目清单(docid/title/book_base/char_count)。检索正文前先用本工具
353
+ 确认书在不在库、篇名长什么样。"""
354
+ return do_find_book(name_like, limit)
355
+
356
+ @app.tool(annotations=READ_ONLY)
357
+ def hj_search(query: str, mode: str = "phrase", title_like: str = "",
358
+ limit: int = 10, offset: int = 0, context_chars: int = 80) -> dict:
359
+ """全库正文检索(trigram FTS5,bm25 相关度排序)。mode=phrase:query 作连续短语
360
+ (引文原句用这个);mode=all_terms:query 按空格拆多词 AND 组合(如「骠骑将军 品级」,
361
+ 概念组合用这个;规范化后不足 3 字的短词以 LIKE 过滤并报 warning)。title_like 限定
362
+ 在某书/某卷内检索(如「唐六典」)。返回 total_hits 全库真实命中总数 + offset 分页。
363
+ 命中只作出处候选与上下文依据,不作页码依据(page_basis=false)。"""
364
+ return do_search(query, mode, title_like, limit, offset, context_chars)
365
+
366
+ @app.tool(annotations=READ_ONLY)
367
+ def hj_get_article(docid: int = 0, title: str = "",
368
+ body_offset: int = 0, length: int = 4000) -> dict:
369
+ """按篇取全文:docid(hj_find_book/hj_search 返回)或精确篇名二选一。全文分片返回
370
+ (单片上限 8000 字,has_more=true 时调大 body_offset 续取)。引用校核须取全文上下文
371
+ 时用本工具,不要只凭检索摘录下判断。"""
372
+ return do_get_article(docid, title, body_offset, length)
373
+
374
+ @app.tool(annotations=READ_ONLY)
375
+ def hj_stats() -> dict:
376
+ """库概况:篇目总数、构建元数据、只读状态、证据政策。"""
377
+ return do_stats()
378
+
379
+ return app
380
+
381
+
382
+ def selftest() -> int:
383
+ checks: dict[str, bool] = {}
384
+ fb = do_find_book("唐六典")
385
+ checks["find_book 唐六典 30 卷"] = fb["total_matches"] == 30
386
+ checks["find_book 含诸卫府卷"] = any("卷二十五·诸卫府" in i["title"] for i in fb["items"])
387
+
388
+ s1 = do_search("骠骑将军", mode="phrase", title_like="唐六典")
389
+ checks["phrase 骠骑将军@唐六典 命中2"] = s1["total_hits"] == 2
390
+ checks["命中含卷五尚书兵部"] = any("卷五" in r["title"] for r in s1["results"])
391
+ checks["命中含卷二十五诸卫府"] = any("卷二十五" in r["title"] for r in s1["results"])
392
+
393
+ s2 = do_search("骠骑将军 车骑将军 折冲", mode="all_terms")
394
+ checks["all_terms 三词组合有命中(血证查询)"] = s2["total_hits"] > 0
395
+ s2b = do_search("骠骑将军 车骑将军 折冲", mode="phrase")
396
+ checks["同串 phrase 0 命中(旧缺陷对照)"] = s2b["total_hits"] == 0
397
+
398
+ s3 = do_search("骠骑将军", mode="phrase")
399
+ checks["total_hits 如实报全库 1061"] = s3["total_hits"] == 1061
400
+ checks["返回带分页 warning"] = any("total_hits=1061" in w for w in s3["warnings"])
401
+
402
+ twentyfive = next(i for i in fb["items"] if "卷二十五·诸卫府" in i["title"])
403
+ art = do_get_article(docid=twentyfive["docid"], body_offset=0, length=2000)
404
+ checks["get_article 取卷二十五全文片"] = art["returned_chars"] == 2000 and art["has_more"]
405
+ art2 = do_get_article(title="唐六典·卷二十五·诸卫府", body_offset=0, length=500)
406
+ checks["get_article 精确篇名可取"] = art2.get("docid") == twentyfive["docid"]
407
+
408
+ st = do_stats()
409
+ checks["stats 只读 66876 篇"] = st["doc_count"] == 66876 and st["query_only"] == 1
410
+ checks["evidence_policy 必带"] = all(
411
+ "evidence_policy" in x for x in (fb, s1, s2, s3, art, st)
412
+ )
413
+
414
+ for k, v in checks.items():
415
+ print(("PASS" if v else "FAIL"), k)
416
+ ok = all(checks.values())
417
+ print("==>", "ALL PASS" if ok else "HAS FAILURE")
418
+ return 0 if ok else 1
419
+
420
+
421
+ def main() -> int:
422
+ parser = argparse.ArgumentParser(description="汉籍语料索引 MCP server(只读)")
423
+ parser.add_argument("--db", default="", help="hj_4_jt_fts.sqlite 路径(缺省=项目内全库)")
424
+ parser.add_argument("--selftest", action="store_true", help="直测 SQL 层(不起 MCP)")
425
+ args = parser.parse_args()
426
+ global DB_PATH
427
+ if args.db:
428
+ DB_PATH = Path(args.db)
429
+ if args.selftest:
430
+ return selftest()
431
+ build_mcp_app().run() # stdio transport
432
+ return 0
433
+
434
+
435
+ if __name__ == "__main__":
436
+ sys.exit(main())