Просмотр исходного кода

feat: R1 —— 检索能力落地,插件重构成共享 lib + 多 skill 结构

结构(按讨论定案):skill = 工作流程,CLI 提供能力,共享知识放 references。

    bin/wikipali            单一入口,插件启用时进 PATH
    bin/wikipali-login      唯一接触密码的程序
    lib/                    errors/sites/creds/client/coords/cmd_read/cmd_site/cmd_write/cli
    references/             api-read · api-write · conventions(跨 skill 共用)
    skills/research · write

原来 997 行的 wp.py 拆成 9 个模块,写路径逻辑未改(回归验证见下)。以后加
skill 只需写一个 SKILL.md,不复制代码、用户 /plugin update 即可拿到。

新增五个只读子命令:forms(词形展开)/ word(词典+形态分析)/ search /
dist(出处分布)/ get(按坐标取文)。

实测中发现并规避的服务端坑,已写进 references/api-read.md:
- sentence?view=paragraph 不带 channels 会 500,lang/channel_type 不能单用,
  所以 get 永远带一个默认 channel(巴利原文 _System_Pali_VRI_);
- 词典释义在 note 字段,description 是词典自身的介绍("词数 7735"),拿错
  会输出一堆无用文本;note 里夹着 MdTpl 模板标记要清掉;
- search-pali-wbw-books 的 count 是词次不是段落数(parivāsa:449 词次 /
  281 段落)。两个数写混会直接污染论文的方法论陈述,输出里显式说明;
- 请求路径含巴利词时 urllib 会 UnicodeEncodeError,必须先百分号编码。

回归:write / channels / whoami --check / write --dry-run 对开发机全部通过;
install.sh 装出的副本可独立运行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 1 неделя назад
Родитель
Сommit
a02c5e24f7

+ 6 - 4
plugins/wikipali/.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "wikipali",
-  "description": "WikiPali 巴利三藏平台的客户端:目前提供以 AI 模型身份写入句子的能力(登录、模型身份 token、channel 选择、access token、分批写入与 count 核对)。",
-  "version": "0.1.0",
+  "description": "WikiPali 巴利三藏平台的客户端:检索与阅读语料做研究(词形展开、全文检索、出处分布、按坐标取原文与译本),以及以 AI 模型身份写入句子。",
+  "version": "0.2.0",
   "author": {
     "name": "visuddhinanda",
     "url": "https://github.com/visuddhinanda"
@@ -12,7 +12,9 @@
   "keywords": [
     "wikipali",
     "pali",
-    "translation",
-    "api-client"
+    "tipitaka",
+    "buddhist-studies",
+    "research",
+    "translation"
   ]
 }

+ 14 - 11
plugins/wikipali/README.md

@@ -2,7 +2,10 @@
 
 [WikiPali](https://www.wikipali.org) 巴利三藏平台的 Claude Code 插件。
 
-目前提供一个 skill:`write`——让 Claude 以 **AI 模型身份**把句子写入句子库。查询类能力(三藏目录、章节原文、全文搜索、词典)在规划中。
+两个 skill:
+
+- **`research`** —— 检索与阅读语料做研究:词形展开、按词形检索、出处分布(分本文/义注/复注)、按坐标取原文与各家译本。只读,不需要登录。
+- **`write`** —— 以 **AI 模型身份**把句子写入句子库。
 
 写入的句子 `editor_uid` 记为 AI 模型的 uid 而不是操作者本人,署名与审计因此是准确的——谁翻的就是谁翻的。
 
@@ -32,11 +35,11 @@
 装好后直接对 Claude 说「把这些译文写进 WikiPali 的某某 channel」即可,它会自己走完流程。手工调用:
 
 ```bash
-python3 ${CLAUDE_PLUGIN_ROOT}/skills/write/scripts/wp.py whoami        # 看当前凭据状态
-python3 ${CLAUDE_PLUGIN_ROOT}/skills/write/scripts/wp_login.py         # 登录(自己跑)
-python3 ${CLAUDE_PLUGIN_ROOT}/skills/write/scripts/wp.py ensure-model --name <模型标识>
-python3 ${CLAUDE_PLUGIN_ROOT}/skills/write/scripts/wp.py channels
-python3 ${CLAUDE_PLUGIN_ROOT}/skills/write/scripts/wp.py write sents.json --channel <uid> --dry-run
+wikipali whoami        # 看当前凭据状态
+wikipali-login         # 登录(自己跑)
+wikipali ensure-model --name <模型标识>
+wikipali channels
+wikipali write sents.json --channel <uid> --dry-run
 ```
 
 句子文件的形状:
@@ -56,9 +59,9 @@ python3 ${CLAUDE_PLUGIN_ROOT}/skills/write/scripts/wp.py write sents.json --chan
 线上四个地址(`www` / `next` × `.org` / `.cc`)共享同一个数据库和密钥,凭据通用,可随时切换:
 
 ```bash
-python3 .../wp.py endpoint          # 列出并标出当前
-python3 .../wp.py endpoint next     # 改默认
-python3 .../wp.py --api next ...    # 只影响这一次调用
+wikipali endpoint          # 列出并标出当前
+wikipali endpoint next     # 改默认
+wikipali --api next ...    # 只影响这一次调用
 ```
 
 `www` 是稳定版、`next` 是最新版**代码**,不是不同的数据环境。较新的端点在稳定版上返回 404,意思是「该站点代码版本还没到」。
@@ -73,13 +76,13 @@ python3 .../wp.py --api next ...    # 只影响这一次调用
 | 模型 token | AI 模型身份,写句子时的 `Authorization` | 30 天,可撤销 |
 | access token | 被委托的 channel 编辑权,写句子时的 body 字段 | 7 天 |
 
-模型自身不是任何 channel 的 owner,它的全部写权限来自你签发的 access token,且受 book 范围约束——**你没有编辑权的 channel,签发阶段就会失败**。凭据泄漏时用 `wp.py revoke` 作废该模型已签出的全部 token。
+模型自身不是任何 channel 的 owner,它的全部写权限来自你签发的 access token,且受 book 范围约束——**你没有编辑权的 channel,签发阶段就会失败**。凭据泄漏时用 `wikipali revoke` 作废该模型已签出的全部 token。
 
 ## 开发
 
 本插件在 [visuddhinanda/mint](https://github.com/visuddhinanda/mint) 的 `plugins/wikipali/` 下开发,与被调用的 Laravel API(`api-v13/`)同仓演进——API 契约一改,插件在同一个提交里跟上。设计文档在 `docs/wikipali-write-skill-design.md`。
 
-端点细节、返回形状与各处陷阱见 `skills/write/references/api.md`。
+端点细节见 `references/api-read.md` 与 `references/api-write.md`;跨 skill 的通用约定(坐标、引用格式、文献层次、译文来源判定)见 `references/conventions.md`。
 
 ## License
 

+ 13 - 0
plugins/wikipali/bin/wikipali

@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+"""WikiPali 客户端入口。只用 Python 标准库,直接跑,不要建虚拟环境。"""
+
+import os
+import sys
+
+_LIB = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'lib')
+sys.path.insert(0, _LIB)
+
+from cli import main  # noqa: E402
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 25 - 17
plugins/wikipali/skills/write/scripts/wp_login.py → plugins/wikipali/bin/wikipali-login

@@ -1,20 +1,20 @@
 #!/usr/bin/env python3
-"""WikiPali 交互式登录——整个 Skill 里唯一接触密码的脚本
+"""WikiPali 交互式登录——整个插件里唯一接触密码的程序
 
 密码只经 getpass 读入内存,不落盘、不进日志、不进对话上下文。
 登录成功后只把 JWT 存进 ~/.wikipali/credentials.json(0600)。
 
 用法:
-    python3 wp_login.py                       # 登录当前默认站点
-    python3 wp_login.py --api next            # 只为本次登录换站点
-    python3 wp_login.py --username someone    # 免去输用户名一步
+    wikipali-login                       # 登录当前默认站点
+    wikipali-login --api next            # 只为本次登录换站点
+    wikipali-login --username someone    # 免去输用户名一步
 
 **必须在真正的终端里跑。** Claude Code 的 `!` 前缀没有交互式终端,
-密码提示无处输入;模型也不该代跑此脚本。请另开一个 shell 执行。
+密码提示无处输入;模型也不该代跑此程序。请另开一个 shell 执行。
 
 确实要在自动化环境里登录时,用 --password-stdin 从管道读密码:
 
-    read -rs PW && printf '%s' "$PW" | python3 wp_login.py --username me --password-stdin
+    read -rs PW && printf '%s' "$PW" | wikipali-login --username me --password-stdin
 
 注意别把密码写进命令行参数或直接敲进 Claude Code 的会话——argv 会进
 ps / shell history,会话内容会进对话上下文,两者都留痕。
@@ -22,13 +22,21 @@ ps / shell history,会话内容会进对话上下文,两者都留痕。
 
 import argparse
 import getpass
+import os
 import sys
 
-import wp
+_LIB = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'lib')
+sys.path.insert(0, _LIB)
+
+import client as wp  # noqa: E402
+from client import fmt_ts, make_client, mask, token_expiry  # noqa: E402
+from creds import CREDS_PATH  # noqa: E402
+from cmd_write import iso_now  # noqa: E402
+from errors import ApiError, WpError  # noqa: E402
 
 
 def main(argv=None):
-    parser = argparse.ArgumentParser(prog="wp_login.py", description="登录 WikiPali 并缓存用户 token")
+    parser = argparse.ArgumentParser(prog="wikipali-login", description="登录 WikiPali 并缓存用户 token")
     parser.add_argument("--api", help="本次登录使用的 API 地址(序号/简称/完整 url)")
     parser.add_argument("--username", help="用户名或邮箱;省略则交互输入")
     parser.add_argument(
@@ -38,8 +46,8 @@ def main(argv=None):
     args = parser.parse_args(argv)
 
     try:
-        client = wp.make_client(args)
-    except wp.WpError as exc:
+        client = make_client(args)
+    except WpError as exc:
         print(f"错误:{exc}", file=sys.stderr)
         return 1
 
@@ -54,7 +62,7 @@ def main(argv=None):
         print(
             "错误:当前不是交互式终端,无法安全地读取密码。\n"
             "  · 请另开一个真正的终端执行本脚本(Claude Code 的 `!` 前缀不行);\n"
-            "  · 或在自动化环境里用管道:... | python3 wp_login.py --username <名字> --password-stdin",
+            "  · 或在自动化环境里用管道:... | wikipali-login --username <名字> --password-stdin",
             file=sys.stderr,
         )
         return 1
@@ -83,14 +91,14 @@ def main(argv=None):
 
     try:
         token = client.call("POST", "v2/sign-in", body={"username": username, "password": password})
-    except wp.ApiError as exc:
+    except ApiError as exc:
         # sign-in 失败时服务端返回 400 + 'invalid token',措辞会让人以为是 token 问题
         if exc.status in (400, 401):
             print("错误:用户名或密码不正确。", file=sys.stderr)
         else:
             print(f"错误:登录失败(HTTP {exc.status}):{exc}", file=sys.stderr)
         return 1
-    except wp.WpError as exc:
+    except WpError as exc:
         print(f"错误:{exc}", file=sys.stderr)
         return 1
     finally:
@@ -102,7 +110,7 @@ def main(argv=None):
 
     try:
         current = client.call("GET", "v2/auth/current", token=token)
-    except wp.WpError as exc:
+    except WpError as exc:
         print(f"错误:token 拿到了但校验失败:{exc}", file=sys.stderr)
         return 1
 
@@ -111,13 +119,13 @@ def main(argv=None):
         "username": current.get("realName"),
         "nickname": current.get("nickName"),
         "token": token,
-        "logged_in_at": wp.iso_now(),
+        "logged_in_at": iso_now(),
     }
     client.save()
 
-    exp = wp.token_expiry(token)
+    exp = token_expiry(token)
     print(f"登录成功:{current.get('nickName')}(realName={current.get('realName')},用作 studio_name)")
-    print(f"token {wp.mask(token)} 到期 {wp.fmt_ts(exp)},已写入 {wp.CREDS_PATH}(0600)")
+    print(f"token {mask(token)} 到期 {fmt_ts(exp)},已写入 {CREDS_PATH}(0600)")
     print("下一步:python3 wp.py ensure-model --name <模型标识>")
     return 0
 

+ 3 - 2
plugins/wikipali/install.sh

@@ -75,9 +75,10 @@ fi
 mkdir -p "$TARGET"
 cp -R "$SRC" "$DEST"
 find "$DEST" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true
-chmod +x "$DEST/skills/write/scripts/wp.py" "$DEST/skills/write/scripts/wp_login.py" "$DEST/install.sh"
+chmod +x "$DEST/bin/wikipali" "$DEST/bin/wikipali-login" "$DEST/install.sh"
 
 echo "已安装 $NAME $VERSION 到 $DEST"
 echo
 echo "下一步(凭据在 ~/.wikipali/,多个副本共用,通常不必重新登录):"
-echo "  python3 $DEST/skills/write/scripts/wp.py whoami"
+echo "  $DEST/bin/wikipali whoami"
+echo "(把 $DEST/bin 加进 PATH 可以直接用 wikipali 命令)"

+ 127 - 0
plugins/wikipali/lib/cli.py

@@ -0,0 +1,127 @@
+"""命令行装配。
+
+读侧(forms/word/search/dist/get)不需要凭据;写侧(ensure-model/channels/grant/write)
+需要,且写入前有确认闸门。登录是独立的 wikipali-login,本入口不接触密码。
+"""
+
+import argparse
+import sys
+
+import cmd_read
+import cmd_site
+import cmd_write
+from client import DEFAULT_BATCH
+from errors import WpError
+
+
+def build_parser():
+    parser = argparse.ArgumentParser(
+        prog='wikipali',
+        description='WikiPali 客户端:检索、阅读、以 AI 模型身份写入',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog='凭据在 ~/.wikipali/credentials.json(0600)。登录请跑 wikipali-login。',
+    )
+    parser.add_argument('--api', help='本次调用使用的 API 地址(序号/简称/完整 url),不写回凭据文件')
+    sub = parser.add_subparsers(dest='command', required=True)
+
+    def add(name, help_text, needs_json=True):
+        p = sub.add_parser(name, help=help_text)
+        if needs_json:
+            p.add_argument('--json', action='store_true', help='输出原始 JSON')
+        return p
+
+    # -- 站点与状态 --------------------------------------------------------
+    p = add('endpoint', '查看 / 切换 API 地址', needs_json=False)
+    p.add_argument('target', nargs='?', help='序号、简称(www/www.cc/next/next.cc/local)或完整 url')
+    p.set_defaults(func=cmd_site.cmd_endpoint)
+
+    p = add('whoami', '显示当前凭据状态', needs_json=False)
+    p.add_argument('--check', action='store_true', help='额外向服务端校验用户 token')
+    p.set_defaults(func=cmd_site.cmd_whoami)
+
+    # -- 读 ----------------------------------------------------------------
+    p = add('forms', '展开词形——一切检索的前置步骤')
+    p.add_argument('word', help='词根或任意变格形')
+    p.add_argument('--limit', type=int, default=3, help='显示几个候选词根,默认 3')
+    p.set_defaults(func=cmd_read.cmd_forms)
+
+    p = add('word', '词典释义与形态分析,用来确认选对了词根')
+    p.add_argument('word')
+    p.add_argument('--lang', default='zh', help='释义语言,默认 zh')
+    p.add_argument('--limit', type=int, default=3, help='最多显示几个词条')
+    p.add_argument('--dicts', type=int, default=3, help='每个词条显示几部词典')
+    p.set_defaults(func=cmd_read.cmd_word)
+
+    p = add('search', '按词形检索段落')
+    p.add_argument('forms', nargs='*', help='逗号或空格分隔的词形;或用 --lemma 自动展开')
+    p.add_argument('--lemma', help='给词根,自动先展开成全部词形再检索')
+    p.add_argument('--bold', action='store_true', help='只要黑体命中(注释书标出的词条)')
+    p.add_argument('--book', help='限定书(用 dist 输出里的 --book 值)')
+    p.add_argument('--tags', help='限定范围,如 vinaya 或 vinaya,mūla;vinaya,aṭṭhakathā')
+    p.add_argument('--limit', type=int, default=50, help='本页条数,默认 50')
+    p.add_argument('--offset', type=int, default=0)
+    p.add_argument('--width', type=int, default=200, help='每条摘要的字符数')
+    p.set_defaults(func=cmd_read.cmd_search)
+
+    p = add('dist', '出处分布:命中散布在哪些书、各多少、什么层次')
+    p.add_argument('forms', nargs='*')
+    p.add_argument('--lemma')
+    p.add_argument('--tags')
+    p.add_argument('--limit', type=int, default=25, help='最多列几部书')
+    p.set_defaults(func=cmd_read.cmd_dist)
+
+    p = add('get', '按坐标取文,如 wikipali get 216:35 216:36')
+    p.add_argument('coords', nargs='+', help='book:paragraph,可给多个')
+    p.add_argument('--channel', action='append',
+                   help='channel uid,可重复;缺省取巴利原文')
+    p.add_argument('--limit', type=int, default=200, help='每次请求最多取几句')
+    p.set_defaults(func=cmd_read.cmd_get)
+
+    # -- 写 ----------------------------------------------------------------
+    p = add('ensure-model', '幂等地建立模型记录并取模型身份 token', needs_json=False)
+    p.add_argument('--name', help='模型标识,如 claude-opus-5(会成为句子作者署名)')
+    p.add_argument('--model', help='底层模型 id')
+    p.add_argument('--url', dest='url', help='模型服务地址')
+    p.add_argument('--description', help='描述')
+    p.add_argument('--privacy', choices=['private', 'public'], default='private')
+    p.set_defaults(func=cmd_write.cmd_ensure_model)
+
+    p = add('revoke', '撤销该模型已签出的全部 token', needs_json=False)
+    p.add_argument('--uid', help='模型 uid,缺省用缓存里的')
+    p.add_argument('-y', '--yes', action='store_true')
+    p.set_defaults(func=cmd_write.cmd_revoke)
+
+    p = add('channels', '列出当前账号可编辑的 channel')
+    p.add_argument('--search', help='按名字过滤')
+    p.set_defaults(func=cmd_write.cmd_channels)
+
+    p = add('grant', '为某个 channel 签发 access token 并缓存', needs_json=False)
+    p.add_argument('channel', nargs='?', help='channel uid / 列表序号 / 名字片段;省略则交互选择')
+    p.add_argument('--book', type=int, default=0, help='限定 book,0 表示不限(默认)')
+    p.add_argument('--force', action='store_true', help='即使缓存未过期也重新签发')
+    p.set_defaults(func=cmd_write.cmd_grant)
+
+    p = add('write', '写入句子', needs_json=False)
+    p.add_argument('file', help='句子 JSON 文件,- 表示从 stdin 读')
+    p.add_argument('--channel', help='目标 channel(uid / 序号 / 名字片段)')
+    p.add_argument('--book', type=int, help='access token 的 book 范围,缺省按句子推断')
+    p.add_argument('--batch', type=int, default=DEFAULT_BATCH, help=f'每批条数,默认 {DEFAULT_BATCH}')
+    p.add_argument('--content-type', default='markdown')
+    p.add_argument('--preview', type=int, default=5, help='确认时预览几条')
+    p.add_argument('--dry-run', action='store_true', help='只做校验与回显,不发请求')
+    p.add_argument('-y', '--yes', action='store_true', help='跳过交互确认(非交互环境必须显式给)')
+    p.set_defaults(func=cmd_write.cmd_write)
+
+    return parser
+
+
+def main(argv=None):
+    args = build_parser().parse_args(argv)
+    try:
+        return args.func(args)
+    except WpError as exc:
+        print(f'错误:{exc}', file=sys.stderr)
+        return 1
+    except KeyboardInterrupt:
+        print('\n已中断。', file=sys.stderr)
+        return 130

+ 178 - 0
plugins/wikipali/lib/client.py

@@ -0,0 +1,178 @@
+"""HTTP 客户端:JSON 请求、线上站点之间出声的 fallback、token 显示辅助。"""
+
+import base64
+import json
+import os
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime, timezone
+
+from creds import bucket_name_for, get_bucket, load_creds, resolve_api_url, save_creds
+from errors import ApiError, WpError
+from sites import ONLINE_URLS, SITES, site_label
+
+
+TOKEN_REFRESH_MARGIN = 3600
+
+DEFAULT_TIMEOUT = 30
+WRITE_TIMEOUT = 120
+DEFAULT_BATCH = 50
+
+
+def mask(token):
+    if not token:
+        return "(无)"
+    if len(token) <= 16:
+        return token[:4] + "…"
+    return token[:8] + "…" + token[-4:]
+
+
+def jwt_payload(token):
+    """不验签地读出 JWT payload,仅用于显示有效期。"""
+    try:
+        part = token.split(".")[1]
+        part += "=" * (-len(part) % 4)
+        return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
+    except Exception:
+        return {}
+
+
+def token_expiry(token):
+    exp = jwt_payload(token).get("exp")
+    return int(exp) if isinstance(exp, (int, float)) else None
+
+
+def fmt_ts(ts):
+    if not ts:
+        return "未知"
+    return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
+
+
+def note(msg):
+    print(msg, file=sys.stderr)
+
+
+def http_json(api_url, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
+    """发一个 JSON 请求,返回解析后的响应体(dict)。
+
+    网络层失败抛 urllib 的异常(由 Client 决定是否 fallback);
+    HTTP 层失败抛 ApiError,带上服务端 message。
+    """
+    # 路径里可能有巴利词(parivāsa),urllib 只接受 ASCII,必须先百分号编码
+    url = api_url + "/" + urllib.parse.quote(path.lstrip("/"), safe="/")
+    if query:
+        url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
+    data = None
+    headers = {"Accept": "application/json", "User-Agent": "wikipali-write-skill"}
+    if body is not None:
+        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
+        headers["Content-Type"] = "application/json"
+    if token:
+        headers["Authorization"] = "Bearer " + token
+    req = urllib.request.Request(url, data=data, headers=headers, method=method)
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            raw = resp.read().decode("utf-8", "replace")
+            status = resp.status
+    except urllib.error.HTTPError as exc:
+        raw = exc.read().decode("utf-8", "replace")
+        status = exc.code
+        payload = safe_json(raw)
+        message = payload.get("message") if isinstance(payload, dict) else None
+        raise ApiError(status, message or f"HTTP {status}", url=url, body=payload or raw)
+    payload = safe_json(raw)
+    if not isinstance(payload, dict):
+        raise ApiError(status, f"响应不是 JSON:{raw[:200]}", url=url, body=raw)
+    if not payload.get("ok", False):
+        raise ApiError(status, payload.get("message") or "请求失败", url=url, body=payload)
+    return payload.get("data")
+
+
+def safe_json(raw):
+    try:
+        return json.loads(raw)
+    except ValueError:
+        return None
+
+
+class Client:
+    """按站点收发请求,并在线上地址之间做出声的 fallback。"""
+
+    def __init__(self, api_url, source, creds, allow_fallback=True):
+        self.api_url = api_url
+        self.source = source
+        self.creds = creds
+        self.bucket_name = bucket_name_for(api_url)
+        self.bucket = get_bucket(creds, self.bucket_name, api_url)
+        self.allow_fallback = allow_fallback and api_url in ONLINE_URLS
+
+    # -- 凭据 ---------------------------------------------------------------
+
+    @property
+    def user_token(self):
+        token = (self.bucket.get("user") or {}).get("token")
+        if not token:
+            raise WpError(
+                "尚未登录。请自己执行(Claude Code 里用 ! 前缀):\n"
+                "    ! python3 " + os.path.join(os.path.dirname(os.path.abspath(__file__)), "wp_login.py")
+            )
+        return token
+
+    @property
+    def model(self):
+        model = self.bucket.get("model") or {}
+        if not model.get("token"):
+            raise WpError("尚未取得模型身份 token。请先跑:python3 wp.py ensure-model --name <模型名>")
+        return model
+
+    def save(self):
+        save_creds(self.creds)
+
+    # -- 请求 ---------------------------------------------------------------
+
+    def fallback_order(self):
+        """同版本的另一域名 → 另一版本的同域名 → 其余。绝不含 local。"""
+        cur = next((s for s in SITES if s["url"] == self.api_url), None)
+        if not cur:
+            return []
+        others = [s for s in SITES if s["key"] != "local" and s["url"] != self.api_url]
+        others.sort(
+            key=lambda s: (
+                0 if s["version"] == cur["version"] else 1,
+                0 if s["domain"] == cur["domain"] else 1,
+            )
+        )
+        return [s["url"] for s in others]
+
+    def call(self, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
+        urls = [self.api_url] + (self.fallback_order() if self.allow_fallback else [])
+        last = None
+        for idx, url in enumerate(urls):
+            try:
+                data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
+            except (urllib.error.URLError, TimeoutError, OSError) as exc:
+                # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
+                last = exc
+                reason = getattr(exc, "reason", exc)
+                if idx + 1 < len(urls):
+                    note(f"⚠ {url} 连接失败({reason}),改用 {urls[idx + 1]}")
+                continue
+            if url != self.api_url:
+                # fallback 成功后本次会话都用它,但不写回凭据文件
+                note(f"⚠ 本次请求实际发往 {url}({site_label(url)})")
+                self.api_url = url
+            return data
+        raise WpError(f"所有可用站点都连不上,最后一次错误:{last}")
+
+    def api_note(self):
+        src = {"cli": "--api", "env": "环境变量", "creds": "凭据文件", "default": "内置默认"}[self.source]
+        return f"{self.api_url}({site_label(self.api_url)},来源:{src})"
+
+
+def make_client(args, allow_fallback=True):
+    creds = load_creds()
+    api_url, source = resolve_api_url(getattr(args, "api", None), creds)
+    return Client(api_url, source, creds, allow_fallback=allow_fallback)

+ 284 - 0
plugins/wikipali/lib/cmd_read.py

@@ -0,0 +1,284 @@
+"""检索与阅读的子命令:forms / word / search / dist / get。
+
+全部只读,不需要凭据。
+"""
+
+import html as html_mod
+import json
+import re
+import sys
+
+from client import make_client, note
+from coords import fmt_coord, fmt_path, parse_coords, text_layer
+from errors import ApiError, WpError, explain_api_error
+
+# 巴利原文本身就是一个 channel(_System_Pali_VRI_)。取原文、取译文、取逐词解析
+# 是同一个调用换 channel。
+PALI_CHANNEL = '00b577c0-13b9-11ee-a05a-b7307efd9ee6'
+
+# 服务端的 sentence?view=paragraph 不带 channels 会 500,所以永远要给一个默认值。
+READ_TIMEOUT = 60
+
+
+def strip_markup(raw, hl='【】', bold='**'):
+    """把服务端返回的 HTML 压成纯文本,保留命中高亮与黑体两种信息。
+
+    命中词用 <span class='hl'> 包,黑体是原文的 <span class="bld">——后者是注释书
+    标出词条的地方,对判断「这段是不是定义」很关键,不能丢。
+    """
+    if not raw:
+        return ''
+    text = raw
+    text = re.sub(r"<span class='hl'>(.*?)</span>", hl[0] + r'\1' + hl[1], text, flags=re.S)
+    text = re.sub(r'<span class="bld">(.*?)</span>', bold + r'\1' + bold, text, flags=re.S)
+    text = re.sub(r"<MdTpl[^>]*></MdTpl>", '', text)
+    text = re.sub(r'<[^>]+>', '', text)
+    text = html_mod.unescape(text)
+    return re.sub(r'\s+', ' ', text).strip()
+
+
+def snippet(text, width, around=None):
+    """截断文本;给了 around 就尽量把它所在的位置露出来。"""
+    if len(text) <= width:
+        return text
+    if around:
+        pos = text.find(around)
+        if pos > width // 2:
+            start = pos - width // 3
+            return '…' + text[start:start + width] + '…'
+    return text[:width] + '…'
+
+
+def emit(args, payload, render):
+    if getattr(args, 'json', False):
+        print(json.dumps(payload, ensure_ascii=False, indent=2))
+    else:
+        render()
+
+
+# ---------------------------------------------------------------------------
+# forms —— 词形展开,一切检索的前置
+# ---------------------------------------------------------------------------
+
+
+def fetch_forms(client, word):
+    try:
+        data = client.call('GET', f'v2/case/{word}', timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, f'展开词形 {word}')
+    return (data or {}).get('rows') or []
+
+
+def cmd_forms(args):
+    client = make_client(args)
+    rows = fetch_forms(client, args.word)
+    if not rows:
+        raise WpError(
+            f'「{args.word}」在语料里找不到任何词形。检查拼写(变音符号是否正确),'
+            '或换一个可能的词根再试。'
+        )
+
+    def render():
+        for idx, row in enumerate(rows[: args.limit], 1):
+            forms = row.get('case') or []
+            total = sum(int(f.get('count') or 0) for f in forms)
+            bold = sum(int(f.get('bold') or 0) for f in forms)
+            mark = '  ← 可能性最高' if idx == 1 else ''
+            print(f'[{idx}] {row.get("word")}  {len(forms)} 形 / 共 {total} 次(黑体 {bold}){mark}')
+            for f in sorted(forms, key=lambda x: -int(x.get('count') or 0)):
+                print(f'      {f.get("word"):<20} {f.get("count"):>5} 次   黑体 {f.get("bold")}')
+        print()
+        print('检索用(第一候选的全部词形):')
+        print('  ' + forms_arg(rows[0]))
+        if len(rows) > 1:
+            print('注意:还有其他候选词根。若目标概念同时有名词与动词两条线,两条都要展开。')
+
+    emit(args, rows, render)
+    return 0
+
+
+def forms_arg(row):
+    """把一个候选的全部词形拼成 search 要的逗号串。"""
+    return ','.join(f.get('word') for f in (row.get('case') or []) if f.get('word'))
+
+
+# ---------------------------------------------------------------------------
+# word —— 词典释义与形态分析,用来确认选对了词根
+# ---------------------------------------------------------------------------
+
+
+def cmd_word(args):
+    client = make_client(args)
+    try:
+        data = client.call('GET', 'v2/dict', query={'word': args.word, 'lang': args.lang},
+                           timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, f'查词典 {args.word}')
+    groups = (data or {}).get('words') or []
+    if not groups:
+        raise WpError(f'词典里没有「{args.word}」。')
+
+    def render():
+        for grp in groups:
+            for w in (grp.get('words') or [])[: args.limit]:
+                print(f'■ {w.get("word")}')
+                for g in (w.get('grammar') or [])[:6]:
+                    print(f'    ← {g.get("parent")}  {g.get("type")} {g.get("grammar")}'
+                          f'  ({g.get("factors")})')
+                for d in (w.get('dict') or [])[: args.dicts]:
+                    # 释义在 note;description 是词典本身的介绍,不是词条内容
+                    meaning = strip_markup(d.get('note') or '')
+                    if not meaning:
+                        continue
+                    print(f'    〔{d.get("shortname")}·{d.get("lang")}〕{snippet(meaning, 220)}')
+                print()
+
+    emit(args, groups, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# search —— 按词形检索段落
+# ---------------------------------------------------------------------------
+
+
+def resolve_key(client, args):
+    """确定检索用的词形串。--lemma 会先跑一次 forms,并把展开结果打出来。"""
+    if args.lemma:
+        rows = fetch_forms(client, args.lemma)
+        if not rows:
+            raise WpError(f'「{args.lemma}」展不出任何词形。')
+        key = forms_arg(rows[0])
+        note(f'⚠ 已把词根「{args.lemma}」展开为 {len(key.split(","))} 个词形:{key}')
+        return key
+    key = ','.join(part.strip() for item in args.forms for part in item.split(',') if part.strip())
+    if not key:
+        raise WpError('没有给出词形。用 --lemma <词根> 自动展开,或直接给逗号分隔的词形。')
+    return key
+
+
+def cmd_search(args):
+    client = make_client(args)
+    key = resolve_key(client, args)
+    query = {'key': key, 'limit': args.limit, 'offset': args.offset}
+    if args.bold:
+        query['bold'] = 'on'
+    if args.book:
+        query['book'] = args.book
+    if args.tags:
+        query['tags'] = args.tags
+    try:
+        data = client.call('GET', 'v2/search-pali-wbw', query=query, timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, '检索')
+    rows = (data or {}).get('rows') or []
+    total = (data or {}).get('count', 0)
+
+    def render():
+        scope = []
+        if args.bold:
+            scope.append('仅黑体')
+        if args.book:
+            scope.append(f'book={args.book}')
+        if args.tags:
+            scope.append(f'tags={args.tags}')
+        print(f'命中 {total} 段,本页 {len(rows)}(offset {args.offset})'
+              + (f'  [{" ".join(scope)}]' if scope else ''))
+        if not rows:
+            print('\n0 条。依次怀疑:词形没展开(用 --lemma)→ 词根选错 → 范围限太窄。')
+            return
+        print()
+        for idx, r in enumerate(rows, 1 + args.offset):
+            coord = fmt_coord(r.get('book'), r.get('paragraph'))
+            print(f'[{idx}] {coord}  {fmt_path(r.get("path"))}   rank {r.get("rank")}')
+            print(f'     {snippet(strip_markup(r.get("highlight")), args.width, "【")}')
+        print(f'\n引用时用坐标 book:paragraph,取原文用:wikipali get {rows[0].get("book")}:'
+              f'{rows[0].get("paragraph")}')
+
+    emit(args, {'count': total, 'rows': rows}, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# dist —— 出处分布
+# ---------------------------------------------------------------------------
+
+
+def cmd_dist(args):
+    client = make_client(args)
+    key = resolve_key(client, args)
+    query = {'key': key}
+    if args.tags:
+        query['tags'] = args.tags
+    try:
+        data = client.call('GET', 'v2/search-pali-wbw-books', query=query, timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, '统计出处分布')
+    rows = (data or {}).get('rows') or []
+
+    def render():
+        total = sum(int(r.get('count') or 0) for r in rows)
+        print(f'{len(rows)} 部书,共 {total} 次词命中\n'
+              '(注意:这里数的是词次,不是段落数。段落数用 search 的 count,'
+              '两者不相等——同一段里出现多次只算一段。)\n')
+        by_layer = {}
+        for r in sorted(rows, key=lambda x: -int(x.get('count') or 0))[: args.limit]:
+            layer = text_layer(r.get('tags'))
+            by_layer[layer] = by_layer.get(layer, 0) + int(r.get('count') or 0)
+            tags = ' '.join(t.get('name') for t in (r.get('tags') or []) if t.get('name'))
+            print(f'{r.get("count"):>5}  {str(r.get("paliTitle"))[:38]:<40} '
+                  f'--book {r.get("pcdBookId")}   [{tags}]')
+        print('\n按文献层次:', end='')
+        for layer in ('mūla', 'aṭṭhakathā', 'ṭīkā', ''):
+            if layer in by_layer:
+                print(f'  {layer or "未标层次"} {by_layer[layer]}', end='')
+        print('\n引用时必须标明层次——把义注的解释当成本文的说法是学术错误。')
+
+    emit(args, {'rows': rows}, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# get —— 按坐标取原文/译文
+# ---------------------------------------------------------------------------
+
+
+def cmd_get(args):
+    client = make_client(args)
+    grouped = parse_coords(args.coords)
+    channels = ','.join(args.channel) if args.channel else PALI_CHANNEL
+
+    collected = []
+    for book, paras in grouped.items():
+        # 服务端不带 channels 会 500,所以 channels 永远要给
+        query = {'view': 'paragraph', 'book': book, 'para': ','.join(str(p) for p in paras),
+                 'channels': channels, 'limit': args.limit}
+        try:
+            data = client.call('GET', 'v2/sentence', query=query, timeout=READ_TIMEOUT)
+        except ApiError as exc:
+            raise explain_api_error(exc, f'取 {book} 的段落')
+        collected.extend((data or {}).get('rows') or [])
+
+    def render():
+        if not collected:
+            print('这些坐标在指定 channel 下没有内容。')
+            print('注意:这是「该 channel 在此处没有文本」,不是「查询失败」——'
+                  '如实报告,不要拿相邻段落或别的译本凑。')
+            return
+        current = None
+        for r in collected:
+            ch = (r.get('channel') or {})
+            head = (r.get('book'), r.get('paragraph'), ch.get('uid'))
+            if head != current:
+                current = head
+                editor = (r.get('editor') or {})
+                who = editor.get('nickName') or editor.get('name') or ''
+                print(f'\n=== {fmt_coord(r.get("book"), r.get("paragraph"))}  '
+                      f'{ch.get("name")}({ch.get("lang")})'
+                      + (f'  作者:{who}' if who else '') + ' ===')
+            text = strip_markup(r.get('content'))
+            print(f'  [{r.get("word_start")}-{r.get("word_end")}] {text}')
+        print(f'\n共 {len(collected)} 句。')
+
+    emit(args, collected, render)
+    return 0

+ 82 - 0
plugins/wikipali/lib/cmd_site.py

@@ -0,0 +1,82 @@
+"""与站点和凭据状态有关的子命令:endpoint / whoami。"""
+
+import sys
+import time
+
+from client import fmt_ts, make_client, mask, note, token_expiry
+from creds import bucket_name_for, get_bucket, resolve_api_url, save_creds, load_creds, CREDS_PATH
+from errors import ApiError, WpError, explain_api_error
+from sites import SITES, expand_site_alias, normalize_api_url, site_label
+
+
+def cmd_endpoint(args):
+    creds = load_creds()
+    current_url, source = resolve_api_url(getattr(args, "api", None), creds)
+
+    if not args.target:
+        for idx, site in enumerate(SITES, 1):
+            mark = "  ← 当前" if site["url"] == current_url else ""
+            print(f"  {idx}) {site['url']:<32} {site['version']} · {site['domain']}{mark}")
+        if source in ("cli", "env"):
+            src = "--api" if source == "cli" else "WIKIPALI_API_URL"
+            note(f"注意:当前地址来自 {src},是一次性覆盖,未写入凭据文件。")
+        if current_url not in [s["url"] for s in SITES]:
+            print(f"  *) {current_url:<32} 自定义地址  ← 当前")
+        print("\n切换:python3 wp.py endpoint <序号|www|www.cc|next|next.cc|local|完整url>")
+        return 0
+
+    url = normalize_api_url(expand_site_alias(args.target))
+    name = bucket_name_for(url)
+    bucket = get_bucket(creds, name, url)
+    bucket["api_url"] = url
+    creds["current"] = name
+    save_creds(creds)
+    print(f"已切换到 {url}({site_label(url)})")
+    if name != "online":
+        note("提示:该地址与线上四站不共用数据库/密钥,凭据是独立的一桶,可能需要重新登录。")
+    return 0
+
+
+def cmd_whoami(args):
+    client = make_client(args)
+    print(f"API      : {client.api_note()}")
+    print(f"凭据文件 : {CREDS_PATH}(桶:{client.bucket_name})")
+
+    user = client.bucket.get("user") or {}
+    if user.get("token"):
+        exp = token_expiry(user["token"])
+        expired = exp is not None and exp < time.time()
+        print(f"用户     : {user.get('username', '?')}  uid={user.get('uid', '?')}")
+        print(f"           token {mask(user['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
+    else:
+        print("用户     : 未登录(! python3 scripts/wp_login.py)")
+
+    model = client.bucket.get("model") or {}
+    if model.get("token"):
+        exp = token_expiry(model["token"])
+        expired = exp is not None and exp < time.time()
+        print(f"模型     : {model.get('name', '?')}  uid={model.get('uid', '?')}")
+        print(f"           token {mask(model['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
+    else:
+        print("模型     : 未建立(python3 wp.py ensure-model --name <模型名>)")
+
+    tokens = client.bucket.get("access_tokens") or {}
+    if tokens:
+        print("access token:")
+        for uid, item in tokens.items():
+            exp = item.get("exp") or token_expiry(item.get("token", ""))
+            expired = exp is not None and exp < time.time()
+            book = item.get("book", 0)
+            scope = "全部 book" if book == 0 else f"book {book}"
+            name = item.get("channel_name") or ""
+            print(f"  {uid[:8]}… {name:<24} {scope:<10} 到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
+    else:
+        print("access token:无(python3 wp.py grant <channel>)")
+
+    if args.check:
+        try:
+            data = client.call("GET", "v2/auth/current", token=client.user_token)
+        except ApiError as exc:
+            raise explain_api_error(exc, "校验用户 token")
+        print(f"\n服务端确认:{data.get('nickName')} / realName={data.get('realName')}(studio_name 用它)")
+    return 0

+ 465 - 0
plugins/wikipali/lib/cmd_write.py

@@ -0,0 +1,465 @@
+"""写入路径的子命令:ensure-model / revoke / channels / grant / write。"""
+
+import json
+import sys
+import time
+from datetime import datetime, timezone
+
+from client import (WRITE_TIMEOUT, TOKEN_REFRESH_MARGIN, DEFAULT_BATCH,
+                    fmt_ts, make_client, mask, note, token_expiry)
+from errors import ApiError, WpError, explain_api_error
+
+
+def cmd_ensure_model(args):
+    client = make_client(args)
+    token = client.user_token
+
+    name = args.name or (client.bucket.get("model") or {}).get("name") or os.environ.get("WIKIPALI_MODEL_NAME")
+    if not name:
+        raise WpError(
+            "必须指定模型名:--name <模型标识>(如 claude-opus-5)。\n"
+            "该名字会成为句子的作者署名,不要用别的模型的名字。"
+        )
+
+    try:
+        current = client.call("GET", "v2/auth/current", token=token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "取当前用户信息")
+    studio_name = current.get("realName")
+    if not studio_name:
+        raise WpError("服务端没有返回 realName,无法确定 studio_name。")
+
+    # 1) 按 studio + keyword 查,keyword 是模糊匹配,客户端自己做精确比对
+    try:
+        listed = client.call(
+            "GET", "v2/ai-model", token=token,
+            query={"view": "studio", "name": studio_name, "keyword": name},
+        )
+    except ApiError as exc:
+        raise explain_api_error(exc, "查询模型列表")
+    rows = (listed or {}).get("rows") or []
+    found = next((r for r in rows if r.get("name") == name), None)
+
+    if found:
+        print(f"已存在模型记录:{name}  uid={found['uid']}")
+    else:
+        body = {"name": name, "studio_name": studio_name, "privacy": args.privacy}
+        for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
+            if value is not None:
+                body[field] = value
+        try:
+            found = client.call("POST", "v2/ai-model", token=token, body=body)
+            print(f"已创建模型记录:{name}  uid={found['uid']}")
+        except ApiError as exc:
+            if exc.status != 409:
+                raise explain_api_error(exc, "创建模型记录")
+            # 并发或模糊匹配漏网:重查一次
+            listed = client.call(
+                "GET", "v2/ai-model", token=token,
+                query={"view": "studio", "name": studio_name, "keyword": name},
+            )
+            rows = (listed or {}).get("rows") or []
+            found = next((r for r in rows if r.get("name") == name), None)
+            if not found:
+                raise WpError(f"服务端说 {name} 已存在(409),但列表里查不到,无法继续。")
+            print(f"已存在模型记录:{name}  uid={found['uid']}")
+
+    # 2) 增量补字段(update 是增量的,未提交的字段保持原值)
+    patch = {}
+    for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
+        if value is not None and found.get(field) != value:
+            patch[field] = value
+    if args.privacy and found.get("privacy") != args.privacy:
+        patch["privacy"] = args.privacy
+    if patch:
+        try:
+            found = client.call("PUT", f"v2/ai-model/{found['uid']}", token=token, body=patch)
+            print(f"已更新字段:{', '.join(patch)}")
+        except ApiError as exc:
+            raise explain_api_error(exc, "更新模型记录")
+
+    # 3) 取模型身份 token
+    try:
+        issued = client.call("GET", f"v2/ai-model-token/{found['uid']}", token=token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "签发模型身份 token")
+
+    client.bucket["model"] = {
+        "uid": issued["uid"],
+        "name": issued["name"],
+        "token": issued["token"],
+        "issued_at": iso_now(),
+    }
+    client.save()
+    exp = token_expiry(issued["token"])
+    print(f"模型身份 token 已缓存:{mask(issued['token'])}  到期 {fmt_ts(exp)}")
+    print(f"写入的句子将署名为该模型(editor_uid={issued['uid']})。")
+    return 0
+
+
+def cmd_revoke(args):
+    client = make_client(args)
+    model = client.bucket.get("model") or {}
+    uid = args.uid or model.get("uid")
+    if not uid:
+        raise WpError("没有可撤销的模型:请给 --uid <模型 uid>,或先跑 ensure-model。")
+    if not args.yes and not confirm(f"将撤销模型 {model.get('name', uid)} 已签出的全部 token,继续?"):
+        print("已取消。")
+        return 1
+    try:
+        data = client.call("DELETE", f"v2/ai-model-token/{uid}", token=client.user_token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "撤销模型 token")
+    if model.get("uid") == uid:
+        client.bucket["model"] = {"uid": uid, "name": model.get("name")}
+        client.save()
+    print(f"已撤销 {data.get('name')} 的全部 token(token_version={data.get('token_version')})。")
+    print("本地缓存的模型 token 已清除,需要写入时请重跑 ensure-model。")
+    return 0
+
+
+def fetch_channels(client, search=None):
+    try:
+        data = client.call(
+            "GET", "v2/channel", token=client.user_token,
+            query={"view": "user-edit", "order": "updated_at", "dir": "desc", "limit": 200, "search": search},
+        )
+    except ApiError as exc:
+        raise explain_api_error(exc, "获取可编辑 channel 列表")
+    return (data or {}).get("rows") or []
+
+
+def cmd_channels(args):
+    client = make_client(args)
+    rows = fetch_channels(client, args.search)
+    if args.json:
+        print(json.dumps(rows, ensure_ascii=False, indent=2))
+        return 0
+    if not rows:
+        print("当前账号没有任何可编辑的 channel。")
+        return 1
+    print(f"可编辑 channel({len(rows)} 个,按更新时间倒序):")
+    for idx, ch in enumerate(rows, 1):
+        print(
+            f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} "
+            f"{ch.get('uid', '')[:8]}…  {ch.get('role', '')}"
+        )
+    return 0
+
+
+def pick_channel(client, given, interactive=True):
+    """返回 (uid, name)。given 可以是 uid、序号或名字片段;为空则交互选择。"""
+    rows = fetch_channels(client)
+    if not rows:
+        raise WpError("当前账号没有任何可编辑的 channel,无法继续。")
+
+    if given:
+        for ch in rows:
+            if ch.get("uid") == given:
+                return ch["uid"], ch.get("name")
+        if given.isdigit() and 1 <= int(given) <= len(rows):
+            ch = rows[int(given) - 1]
+            return ch["uid"], ch.get("name")
+        matched = [c for c in rows if given.lower() in (c.get("name") or "").lower()]
+        if len(matched) == 1:
+            return matched[0]["uid"], matched[0].get("name")
+        if len(matched) > 1:
+            names = ", ".join(c.get("name", "") for c in matched[:5])
+            raise WpError(f"「{given}」匹配到多个 channel:{names}…… 请给完整 uid。")
+        # 不在可编辑列表里的 uid:直接用,但回显不出名字
+        if len(given) >= 32:
+            note(f"⚠ {given} 不在可编辑列表中,仍按 uid 使用——签发 access token 时可能返回 count: 0。")
+            return given, None
+        raise WpError(f"找不到 channel:{given}")
+
+    if not (interactive and sys.stdin.isatty()):
+        raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wp.py channels` 再用 --channel 指定。")
+
+    print("可编辑 channel:")
+    for idx, ch in enumerate(rows, 1):
+        print(f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} {ch.get('uid', '')[:8]}…")
+    raw = input("选择序号:").strip()
+    if not raw.isdigit() or not (1 <= int(raw) <= len(rows)):
+        raise WpError("输入无效。")
+    ch = rows[int(raw) - 1]
+    return ch["uid"], ch.get("name")
+
+
+def cached_access_token(client, channel_uid, book):
+    item = (client.bucket.get("access_tokens") or {}).get(channel_uid)
+    if not item or not item.get("token"):
+        return None
+    # book 0 是「不限 book」,能覆盖任何请求;否则必须完全一致
+    if item.get("book", 0) != 0 and item.get("book") != book:
+        return None
+    exp = item.get("exp") or token_expiry(item["token"])
+    if exp and exp - time.time() < TOKEN_REFRESH_MARGIN:
+        return None
+    return item
+
+
+def grant_access_token(client, channel_uid, channel_name, book, force=False):
+    if not force:
+        cached = cached_access_token(client, channel_uid, book)
+        if cached:
+            return cached
+
+    # book 必须是整数:服务端用 !== 严格比较,"1" !== 1 恒真会导致鉴权失败
+    payload = [{"res_type": "channel", "res_id": channel_uid, "power": "edit", "book": int(book)}]
+    try:
+        data = client.call("POST", "v2/access-token", token=client.user_token, body={"payload": payload})
+    except ApiError as exc:
+        raise explain_api_error(exc, "签发 access token")
+    rows = (data or {}).get("rows") or []
+    if not rows:
+        # 无权时服务端静默跳过该条,rows 为空——等同 403,绝不能继续写
+        raise WpError(
+            f"签发 access token 返回 count: 0,说明当前账号对 channel {channel_uid} 没有编辑权。\n"
+            "不要继续写入。请确认选对了 channel,或让 owner 授予 ≥ editor 权限。"
+        )
+    row = rows[0]
+    item = {
+        "token": row["token"],
+        "book": int(book),
+        "exp": (row.get("payload") or {}).get("exp"),
+        "granted_at": iso_now(),
+    }
+    if channel_name:
+        item["channel_name"] = channel_name
+    client.bucket.setdefault("access_tokens", {})[channel_uid] = item
+    client.save()
+    return item
+
+
+def cmd_grant(args):
+    client = make_client(args)
+    uid, name = pick_channel(client, args.channel)
+    item = grant_access_token(client, uid, name, args.book, force=args.force)
+    scope = "全部 book" if item["book"] == 0 else f"book {item['book']}"
+    print(f"channel : {name or '(未知)'}  {uid}")
+    print(f"范围    : {scope}")
+    print(f"token   : {mask(item['token'])}  到期 {fmt_ts(item.get('exp'))}")
+    return 0
+
+
+SENT_REQUIRED = ("book_id", "paragraph", "word_start", "word_end", "content")
+
+
+def load_sentences(args):
+    if args.file == "-":
+        raw = sys.stdin.read()
+    else:
+        try:
+            with open(args.file, "r", encoding="utf-8") as fh:
+                raw = fh.read()
+        except OSError as exc:
+            raise WpError(f"读不了输入文件:{exc}")
+    try:
+        data = json.loads(raw)
+    except ValueError as exc:
+        raise WpError(f"输入不是合法 JSON:{exc}")
+
+    default_channel = None
+    if isinstance(data, dict):
+        default_channel = data.get("channel_uid") or data.get("channel")
+        data = data.get("sentences")
+    if not isinstance(data, list) or not data:
+        raise WpError('输入必须是句子数组,或 {"channel_uid": ..., "sentences": [...]},且非空。')
+    return data, default_channel
+
+
+def normalize_sentences(rows, channel_uid, default_content_type):
+    out = []
+    for idx, row in enumerate(rows):
+        if not isinstance(row, dict):
+            raise WpError(f"第 {idx + 1} 条不是对象。")
+        missing = [f for f in SENT_REQUIRED if row.get(f) is None]
+        if missing:
+            raise WpError(f"第 {idx + 1} 条缺字段:{', '.join(missing)}")
+        try:
+            sent = {
+                "book_id": int(row["book_id"]),
+                "paragraph": int(row["paragraph"]),
+                "word_start": int(row["word_start"]),
+                "word_end": int(row["word_end"]),
+                "content": str(row["content"]),
+                "content_type": row.get("content_type") or default_content_type,
+                "channel_uid": row.get("channel_uid") or channel_uid,
+            }
+        except (TypeError, ValueError) as exc:
+            raise WpError(f"第 {idx + 1} 条字段类型不对:{exc}")
+        if not sent["channel_uid"]:
+            raise WpError(f"第 {idx + 1} 条没有 channel_uid,且未通过 --channel 指定。")
+        out.append(sent)
+    return out
+
+
+def sent_key(sent):
+    return (
+        int(sent["book_id"]),
+        int(sent["paragraph"]),
+        int(sent["word_start"]),
+        int(sent["word_end"]),
+        sent["channel_uid"],
+    )
+
+
+def row_key(row):
+    channel = row.get("channel") or {}
+    return (
+        int(row.get("book", -1)),
+        int(row.get("paragraph", -1)),
+        int(row.get("word_start", -1)),
+        int(row.get("word_end", -1)),
+        channel.get("uid"),
+    )
+
+
+def confirm(question):
+    if not sys.stdin.isatty():
+        return False
+    answer = input(f"{question} [y/N] ").strip().lower()
+    return answer in ("y", "yes")
+
+
+def iso_now():
+    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def cmd_write(args):
+    client = make_client(args)
+    # 先确认凭据齐备再解析输入:缺 token 时不该让用户看半张回显
+    client.user_token
+    model = client.model
+    rows, file_channel = load_sentences(args)
+
+    channel_hint = args.channel or file_channel
+    uid = name = None
+    if channel_hint or not any(r.get("channel_uid") for r in rows if isinstance(r, dict)):
+        uid, name = pick_channel(client, channel_hint)
+    sentences = normalize_sentences(rows, uid, args.content_type)
+
+    channels = sorted({s["channel_uid"] for s in sentences})
+    books = sorted({s["book_id"] for s in sentences})
+    names = {uid: name} if uid else {}
+    for cuid in channels:
+        if cuid not in names:
+            names[cuid] = channel_display_name(client, cuid)
+
+    # 写入前的确认:出问题时必须知道是哪一版代码、写进了哪个 channel
+    print("=" * 72)
+    print(f"API      : {client.api_note()}")
+    for cuid in channels:
+        print(f"channel  : {names.get(cuid) or '(未知)'}  {cuid}")
+    print(f"book     : {', '.join(str(b) for b in books)}")
+    print(f"模型身份 : {model.get('name')}  uid={model.get('uid')}")
+    print(f"句子数   : {len(sentences)}(每批 {args.batch})")
+    print("-" * 72)
+    for sent in sentences[: args.preview]:
+        summary = sent["content"].replace("\n", " ")
+        if len(summary) > 50:
+            summary = summary[:50] + "…"
+        print(f"  {sent['book_id']}-{sent['paragraph']}-{sent['word_start']}-{sent['word_end']}  {summary}")
+    if len(sentences) > args.preview:
+        print(f"  …… 其余 {len(sentences) - args.preview} 条")
+    print("-" * 72)
+    print("⚠ 相同位置(book/paragraph/word_start/word_end/channel)的已有句子将被覆盖。")
+    print("=" * 72)
+
+    if args.dry_run:
+        print("--dry-run:未发送任何请求。")
+        return 0
+    if not args.yes and not confirm("确认写入?"):
+        print("已取消,未写入任何内容。")
+        return 1
+
+    # 每个 channel 一张 access token(缓存命中就不重签)
+    tokens = {}
+    for cuid in channels:
+        book_scope = 0 if len(books) > 1 else books[0]
+        if args.book is not None:
+            book_scope = args.book
+        item = grant_access_token(client, cuid, names.get(cuid), book_scope)
+        tokens[cuid] = item["token"]
+
+    written = {}
+    failed = []
+    model_token = model["token"]
+    for start in range(0, len(sentences), args.batch):
+        batch = sentences[start : start + args.batch]
+        body = {
+            "sentences": [
+                {
+                    "book_id": s["book_id"],
+                    "paragraph": s["paragraph"],
+                    "word_start": s["word_start"],
+                    "word_end": s["word_end"],
+                    "channel_uid": s["channel_uid"],
+                    "content": s["content"],
+                    "content_type": s["content_type"],
+                    "access_token": tokens[s["channel_uid"]],
+                }
+                for s in batch
+            ]
+        }
+        try:
+            data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
+        except ApiError as exc:
+            if exc.status != 401:
+                raise explain_api_error(exc, "写入句子")
+            # 模型 token 过期或被撤销:重取一次再试,仍失败才提示重新登录
+            model_token = refresh_model_token(client)
+            try:
+                data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
+            except ApiError as retry_exc:
+                raise explain_api_error(retry_exc, "写入句子(已重签模型 token 后重试)")
+        returned = (data or {}).get("rows") or []
+        for row in returned:
+            written[row_key(row)] = row
+        got = len(returned)
+        print(f"批次 {start // args.batch + 1}: 提交 {len(batch)},服务端确认 {got}")
+        if got < len(batch):
+            # HTTP 200 不等于全部写入:逐句鉴权失败是静默 continue 掉的
+            for s in batch:
+                if sent_key(s) not in written:
+                    failed.append(s)
+
+    print("-" * 72)
+    print(f"合计提交 {len(sentences)} 条,确认写入 {len(written)} 条。")
+    sample = next(iter(written.values()), None)
+    if sample:
+        editor = (sample.get("editor") or {}).get("nickName") or (sample.get("editor") or {}).get("name")
+        print(f"署名核对:第一条的 editor = {editor}")
+    if failed:
+        print(f"⚠ 有 {len(failed)} 条未写入(服务端逐句鉴权失败会静默跳过):")
+        for s in failed[:10]:
+            print(f"  {s['book_id']}-{s['paragraph']}-{s['word_start']}-{s['word_end']}  channel={s['channel_uid'][:8]}…")
+        if len(failed) > 10:
+            print(f"  …… 其余 {len(failed) - 10} 条")
+        return 1
+    return 0
+
+
+def channel_display_name(client, uid):
+    try:
+        data = client.call("GET", f"v2/channel/{uid}", token=client.user_token)
+    except (ApiError, WpError):
+        return None
+    if isinstance(data, dict):
+        return data.get("name")
+    return None
+
+
+def refresh_model_token(client):
+    note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
+    model = client.bucket.get("model") or {}
+    if not model.get("uid"):
+        raise WpError("缓存里没有模型 uid,无法重签。请跑:python3 wp.py ensure-model --name <模型名>")
+    try:
+        issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
+    except ApiError as exc:
+        raise explain_api_error(exc, "重新签发模型 token")
+    model.update({"uid": issued["uid"], "name": issued["name"], "token": issued["token"], "issued_at": iso_now()})
+    client.bucket["model"] = model
+    client.save()
+    return issued["token"]

+ 61 - 0
plugins/wikipali/lib/coords.py

@@ -0,0 +1,61 @@
+"""坐标与引用。
+
+WikiPali 的最小可引用单位是 (book, paragraph),句子再细分到 word_start/word_end。
+一切给用户看的内容都必须带得回坐标——这是研究型产出可信度的地基。
+
+坐标的书写形式统一为 `book:paragraph`,例如 `216:35`。
+"""
+
+import re
+
+from errors import WpError
+
+COORD_RE = re.compile(r'^\s*(\d+)\s*[:\-_]\s*(\d+)\s*$')
+
+
+def parse_coord(text):
+    """把 '216:35' 解析成 (216, 35)。也接受 216-35 / 216_35。"""
+    m = COORD_RE.match(str(text))
+    if not m:
+        raise WpError(f"坐标格式不对:{text}(应为 book:paragraph,如 216:35)")
+    return int(m.group(1)), int(m.group(2))
+
+
+def parse_coords(items):
+    """解析一串坐标,按 book 分组,返回 {book: [paragraph, ...]}(去重、保序)。"""
+    grouped = {}
+    for item in items:
+        for part in str(item).split(','):
+            if not part.strip():
+                continue
+            book, para = parse_coord(part)
+            paras = grouped.setdefault(book, [])
+            if para not in paras:
+                paras.append(para)
+    return grouped
+
+
+def fmt_coord(book, paragraph):
+    return f"{book}:{paragraph}"
+
+
+def fmt_path(path, sep=' › ', max_items=4):
+    """把检索结果的 path 数组压成一行章节路径。"""
+    if not path:
+        return ''
+    titles = [p.get('title', '') for p in path if isinstance(p, dict) and p.get('title')]
+    if len(titles) > max_items:
+        titles = [titles[0], '…'] + titles[-(max_items - 2):]
+    return sep.join(titles)
+
+
+def text_layer(tags):
+    """按 tags 判断文献层次:本文 / 义注 / 复注。引用时必须标明,混用是学术错误。"""
+    names = {t.get('name') for t in (tags or []) if isinstance(t, dict)}
+    if 'ṭīkā' in names:
+        return 'ṭīkā'
+    if 'aṭṭhakathā' in names:
+        return 'aṭṭhakathā'
+    if 'mūla' in names:
+        return 'mūla'
+    return ''

+ 80 - 0
plugins/wikipali/lib/creds.py

@@ -0,0 +1,80 @@
+"""凭据文件:~/.wikipali/credentials.json(0600)。
+
+只存 token,不存密码。线上四地址共用 online 桶,开发机 local,其余自成一桶。
+"""
+
+import json
+import os
+import stat
+
+from errors import WpError
+from sites import DEFAULT_API_URL, LOCAL_URL, ONLINE_URLS, expand_site_alias, normalize_api_url
+
+
+CREDS_DIR = os.path.join(os.path.expanduser("~"), ".wikipali")
+CREDS_PATH = os.path.join(CREDS_DIR, "credentials.json")
+
+
+def load_creds():
+    if not os.path.exists(CREDS_PATH):
+        return {"current": "online"}
+    try:
+        with open(CREDS_PATH, "r", encoding="utf-8") as fh:
+            data = json.load(fh)
+    except (OSError, ValueError) as exc:
+        raise WpError(f"凭据文件无法读取({CREDS_PATH}):{exc}")
+    if not isinstance(data, dict):
+        raise WpError(f"凭据文件格式不对({CREDS_PATH}),应为 JSON 对象")
+    data.setdefault("current", "online")
+    return data
+
+
+def save_creds(creds):
+    os.makedirs(CREDS_DIR, mode=0o700, exist_ok=True)
+    tmp = CREDS_PATH + ".tmp"
+    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
+    fd = os.open(tmp, flags, 0o600)
+    try:
+        with os.fdopen(fd, "w", encoding="utf-8") as fh:
+            json.dump(creds, fh, ensure_ascii=False, indent=2)
+            fh.write("\n")
+    except Exception:
+        os.unlink(tmp)
+        raise
+    os.replace(tmp, CREDS_PATH)
+    os.chmod(CREDS_PATH, stat.S_IRUSR | stat.S_IWUSR)
+
+
+def bucket_name_for(api_url):
+    """凭据桶名。线上四地址共用 online 桶;开发机 local;其余地址自成一桶。"""
+    if api_url in ONLINE_URLS:
+        return "online"
+    if api_url == LOCAL_URL:
+        return "local"
+    return "site:" + api_url
+
+
+def get_bucket(creds, name, api_url=None):
+    bucket = creds.setdefault(name, {})
+    bucket.setdefault("api_url", api_url or (DEFAULT_API_URL if name == "online" else LOCAL_URL))
+    bucket.setdefault("user", {})
+    bucket.setdefault("model", {})
+    bucket.setdefault("access_tokens", {})
+    return bucket
+
+
+def resolve_api_url(cli_api, creds):
+    """地址来源优先级:--api > 环境变量 > 凭据文件 > 内置默认。
+
+    前两者是一次性覆盖,不写回凭据文件——否则「上周试了一次 next」会一直粘着。
+    """
+    if cli_api:
+        return normalize_api_url(expand_site_alias(cli_api)), "cli"
+    env = os.environ.get("WIKIPALI_API_URL")
+    if env:
+        return normalize_api_url(expand_site_alias(env)), "env"
+    current = creds.get("current", "online")
+    bucket = creds.get(current)
+    if isinstance(bucket, dict) and bucket.get("api_url"):
+        return normalize_api_url(bucket["api_url"]), "creds"
+    return DEFAULT_API_URL, "default"

+ 37 - 0
plugins/wikipali/lib/errors.py

@@ -0,0 +1,37 @@
+"""面向用户的错误类型,以及把 HTTP 状态翻译成人话。"""
+
+
+class WpError(Exception):
+    """面向用户的错误:main() 捕获后只打印 message,不打印堆栈。"""
+
+
+class ApiError(WpError):
+    def __init__(self, status, message, url=None, body=None):
+        self.status = status
+        self.url = url
+        self.body = body
+        super().__init__(message)
+
+
+def explain_api_error(exc, what):
+    """把 HTTP 状态翻译成对操作者有意义的话(见 references/api.md 的错误约定)。"""
+    if exc.status == 401:
+        return WpError(
+            f"{what}:401 凭据失效或已被撤销。\n"
+            "  · 用户 token 失效 → 重新登录:! python3 scripts/wp_login.py\n"
+            "  · 模型 token 失效或被撤销 → 重跑:python3 wp.py ensure-model\n"
+            "  不要自动重试。"
+        )
+    if exc.status == 403:
+        return WpError(f"{what}:403 无权限(不是 channel 的 owner/协作者,或不是模型 owner 本人)。")
+    if exc.status == 404:
+        return WpError(
+            f"{what}:404。若这是较新的端点,可能是当前站点跑的是稳定版代码、端点尚未上线;\n"
+            "  可切到最新版试试:python3 wp.py endpoint next\n"
+            "  否则才是资源真的不存在。"
+        )
+    if exc.status == 409:
+        return WpError(f"{what}:409 同名记录已存在。")
+    if exc.status == 422:
+        return WpError(f"{what}:422 参数校验失败——{exc}")
+    return WpError(f"{what}:HTTP {exc.status} {exc}")

+ 59 - 0
plugins/wikipali/lib/sites.py

@@ -0,0 +1,59 @@
+"""站点清单与地址解析。
+
+四个线上地址共享同一个数据库和同一把 jwt 密钥,凭据完全通用;
+.org / .cc 是地区可达性,www / next 是代码版本(不是数据环境)。
+开发机是另一个库、另一把密钥,故单独一桶,且永不作为自动 fallback 目标。
+"""
+
+import urllib.parse
+
+from errors import WpError
+
+
+SITES = [
+    {"key": "www", "url": "https://www.wikipali.org/api", "version": "稳定版", "domain": ".org"},
+    {"key": "www.cc", "url": "https://www.wikipali.cc/api", "version": "稳定版", "domain": ".cc"},
+    {"key": "next", "url": "https://next.wikipali.org/api", "version": "最新版", "domain": ".org"},
+    {"key": "next.cc", "url": "https://next.wikipali.cc/api", "version": "最新版", "domain": ".cc"},
+    {"key": "local", "url": "http://127.0.0.1:8000/api", "version": "开发机", "domain": "本机"},
+]
+
+ONLINE_URLS = [s["url"] for s in SITES if s["key"] != "local"]
+LOCAL_URL = SITES[-1]["url"]
+DEFAULT_API_URL = SITES[0]["url"]
+
+
+def normalize_api_url(url):
+    url = url.rstrip("/")
+    parsed = urllib.parse.urlparse(url)
+    if parsed.scheme not in ("http", "https"):
+        raise WpError(f"API 地址必须以 http:// 或 https:// 开头:{url}")
+    host = (parsed.hostname or "").lower()
+    if parsed.scheme == "http" and host not in ("127.0.0.1", "localhost", "::1"):
+        raise WpError(f"只有 127.0.0.1 / localhost 允许用 http://,其余必须 https://:{url}")
+    return url
+
+
+def expand_site_alias(value):
+    """把序号 / 简称展开成完整 url;已是 url 则原样返回。"""
+    value = value.strip()
+    if value.isdigit():
+        idx = int(value) - 1
+        if 0 <= idx < len(SITES):
+            return SITES[idx]["url"]
+        raise WpError(f"站点序号超出范围:{value}(可选 1-{len(SITES)})")
+    for site in SITES:
+        if value == site["key"]:
+            return site["url"]
+    if "://" in value:
+        return value
+    raise WpError(
+        f"无法识别的站点:{value}。可用简称:" + " / ".join(s["key"] for s in SITES) + ",或直接给完整 url"
+    )
+
+
+def site_label(api_url):
+    for site in SITES:
+        if site["url"] == api_url:
+            return f"{site['version']} · {site['domain']}"
+    return "自定义地址"

+ 102 - 0
plugins/wikipali/references/api-read.md

@@ -0,0 +1,102 @@
+# WikiPali API 参考(检索与阅读)
+
+读端**全部不需要凭据**。响应统一是 `{ ok, data, message }`。
+坐标、引用、层次等通用规矩见 `conventions.md`。
+
+## 1. 词形展开 —— `GET /v2/case/{词}`
+
+输入任意变格形或词根,返回候选词典原型,按可能性排序。
+
+```
+data: { rows: [ { word, count, case: [ {word, count, bold}, ... ] } ], count }
+```
+
+`rows[0]` 是可能性最高的候选;`case` 是**该词根在语料中实际出现过的全部词形**,
+每项带出现次数与其中的黑体次数。
+
+**这是一切检索的前置**:`wbw_templates` 索引的是变格形,拿词典形直接查会返回 0 条
+且不报错。
+
+## 2. 词典 —— `GET /v2/dict?word={词}&lang={zh|en|jp|…}`
+
+```
+data.words[].words[] = {
+  word, anchor, factors, parents,
+  grammar: [ {word, type, grammar, parent, factors, confidence} ],   ← 形态分析:形 → 根
+  dict:    [ {shortname, dictname, lang, note, dict_id} ]            ← 释义在 note
+}
+```
+
+⚠ **释义在 `note` 字段**,`description` 是词典本身的介绍("词数 7735"之类),不是词条
+内容。`note` 里可能夹着 `<MdTpl …></MdTpl>` 模板标记,要清掉。
+
+`case`/`grammar` 的方向是 **形 → 根**,用来确认"我选对了词根";**根 → 全部形**是
+`/v2/case` 的活。
+
+## 3. 检索 —— `GET /v2/search-pali-wbw`
+
+| 参数 | 说明 |
+|---|---|
+| `key` | **逗号分隔的词形列表**(不是词根)。多个词形之间是 OR |
+| `bold` | `on` 只要黑体命中、`off` 只要非黑体。黑体是注释书标出词条的地方 |
+| `book` | 限定书,值用 `search-pali-wbw-books` 返回的 `pcdBookId` |
+| `tags` | 范围限定,`tag1,tag2;tag3` —— 组间 OR、组内 AND |
+| `limit` / `offset` | 分页。`limit=200` 实测正常 |
+| ~~`view`~~ / ~~`type`~~ | **实测无影响**,源码也没读,可省略 |
+
+```
+data: { count: 命中段落数, rows: [ { book, paragraph, rank, path, paliTitle, highlight } ] }
+```
+
+- `rank` = `sum(weight)`,黑体权重更高;
+- `path` 是章节路径数组,每项 `{book, paragraph, title, level}`;
+- `highlight` 里命中词包在 `<span class='hl'>`,**并保留原文的 `<span class="bld">`**——
+  后者是黑体,判断"这段是不是定义"要靠它,清洗 HTML 时不能一并丢掉。
+
+## 4. 出处分布 —— `GET /v2/search-pali-wbw-books?key={词形,…}`
+
+```
+data.rows[] = { pcdBookId, count, book, paragraph, paliTitle, tags: [{name}] }
+```
+
+⚠ **`count` 数的是词次,不是段落数**。同一段里出现多次算多次。段落数要看
+`search-pali-wbw` 的 `count`。实测 `parivāsa`:词次 449、段落 281。方法论陈述里
+写错这两个数是硬伤。
+
+`tags` 含 `mūla` / `aṭṭhakathā` / `ṭīkā`,用来区分本文、义注、复注。
+
+## 5. 取文 —— `GET /v2/sentence`
+
+```
+view=paragraph&book={book}&para={p1,p2,…}&channels={uid,…}   按段落取
+view=chapter&book={book}&para={章节 para}&channels={uid,…}    取整章
+```
+
+⚠ **`channels` 参数是必需的**。不带 `channels` 会 **500**(实测;`lang` / `channel_type`
+单独用同样 500,它们只能与 `channels` 并用)。所以客户端必须永远给一个默认 channel。
+
+巴利原文的 channel:`_System_Pali_VRI_`,uid `00b577c0-13b9-11ee-a05a-b7307efd9ee6`。
+
+返回的每行是一个句子(不是整段):`{id, content, content_type, html, book, paragraph,
+word_start, word_end, editor, channel, updated_at}`。按 `word_start` 排序拼起来才是整段。
+
+⚠ 返回里字段名是 `book`(不是 `book_id`)。
+
+## 6. 目录 —— `GET /v2/palitext`
+
+`view=book-toc` / `chapter` / `chapter_children` / `children` / `paragraph`,
+参数 `book` / `para` / `series`。
+
+## 已知故障
+
+| 端点 | 现象 |
+|---|---|
+| `GET /v2/search?view=pali` | **500**。走 gRPC 的 `tulip` 服务,线上不可达 |
+| `GET /v2/search-book-list` | **500**,同上 |
+
+这两个是**词组/短语**检索(多词按全文匹配)。单词检索走 `search-pali-wbw`,不受影响。
+遇到需要短语检索时,把短语拆成词分别展开词形再检索。
+
+`GET /v2/search?view=title`(按标题)与 `view=page`(按页码)不走 gRPC,可用。
+
+⚠ `search` 的 `key` 以 `para` 开头、或首字母是 `M/P/T/V/O` 时会被劫持到页码检索分支。

+ 0 - 0
plugins/wikipali/skills/write/references/api.md → plugins/wikipali/references/api-write.md


+ 81 - 0
plugins/wikipali/references/conventions.md

@@ -0,0 +1,81 @@
+# 通用约定
+
+**本文件的规则对本插件的所有 skill 一律有效**(研究、写入,以及以后加的任何流程)。
+单个 skill 只写自己流程特有的部分,共同的规矩都在这里——改一处,全部生效。
+
+## 坐标
+
+WikiPali 的最小可引用单位是 `book:paragraph`,例如 `216:35`。句子在段内再细分
+`word_start`–`word_end`。完整定位一条内容需要三样:
+
+```
+book : paragraph  +  word_start-word_end  +  channel_uid
+```
+
+`channel` 是**译本/版本**的载体:巴利原文、缅文逐词解析、各家汉译,都是同一坐标下
+的不同 channel。所以"取原文"和"取某语言译文"是同一个操作换 channel。
+
+读端与写端共用这一套坐标——检索到的位置,就是能写入的位置。
+
+## 引用格式
+
+> ⚠ **临时格式**,正式规范待定(见 `docs/wikipali-research-agent-design.md` §3.4)。
+> 规范给出后只改本节,所有 skill 自动跟上。
+
+```
+Cūḷavaggapāḷi, Pārivāsikakkhandhaka (VN 216:35)          ← 本文
+Samantapāsādikā, Pārivāsikavattakathā (SP-aṭṭ 141:63)     ← 义注,已标层次
+Nissaya(缅文,channel: nissaya)(216:35)                  ← 译文,标明语言与来源
+AI-汉译-Nissaya(**AI 生成**,deepseek-v3)(216:35)         ← 机器译文必须标注
+```
+
+书名与章节路径直接取检索结果的 `paliTitle` 与 `path` 字段,**不要自己拼**。
+
+## 文献层次必须标明
+
+`mūla`(本文)、`aṭṭhakathā`(义注)、`ṭīkā`(复注)是不同层次的权威。层次信息来自
+`dist` 输出里的 tags。
+
+**把义注的解释当成经律本身的说法是学术错误,不是措辞问题。** 引用时必须让读者看出
+这句话出自哪一层。
+
+## 译文来源的判定
+
+引用译文前必须判断人译还是机译。两个信号,**任一命中就按机器译文标注**:
+
+1. **作者是 AI 模型**——`get` 返回里的作者若是模型而非人类用户,该译文确定是机器
+   生成的,标注时连模型名一起写;
+2. **channel 名字含 "AI" 等字样**——库里存在人工用自己账号上传的机器译文(如
+   `Nissaya的AI翻译`、`Norbu AI Translations`),此时信号 1 不成立。
+
+两个都不命中时**不要主动断言"这是人译"**——只如实标出 channel 名与作者。
+
+## 空结果要诚实
+
+区分三件事,对用户的下一步完全不同:
+
+| 现象 | 含义 |
+|---|---|
+| 检索 0 条 | 多半是词形没展开(见下),不是"没有材料" |
+| 某坐标取不到某 channel 的内容 | 该译本在此处没有文本。**如实说,不要拿相邻段落或别的译本凑** |
+| 请求报错 | 工具或服务的问题,不是语料的问题 |
+
+## 检索前必须展开词形
+
+语料索引的是**变格形**(`parivāsaṃ` / `parivāso` / …),不是词典形(`parivāsa`)。
+拿词典形直接检索会**返回 0 条且不报错**——看起来像"搜过了,没有"。
+
+所以任何检索都必须先 `wikipali forms <词>`(或给 `search --lemma`)。
+
+## 站点
+
+线上四个地址(`www`/`next` × `.org`/`.cc`)共享同一个数据库和密钥,凭据通用。
+`www` 是稳定版、`next` 是最新版**代码**,不是不同的数据环境。较新的端点在稳定版上
+返回 404,意思是"该站点代码版本还没到",不是"资源不存在"。
+
+`wikipali endpoint` 查看与切换,`--api` 只影响单次调用。
+
+## 凭据
+
+`~/.wikipali/credentials.json`(0600)。**任何 skill 都不得打印 token 全文,也不得
+`cat` 这个文件。** 密码只由 `wikipali-login` 接触,且必须由用户本人在真正的终端里跑。

+ 131 - 0
plugins/wikipali/skills/research/SKILL.md

@@ -0,0 +1,131 @@
+---
+name: research
+description: "Use this skill to research Pali Buddhist texts with WikiPali's corpus — finding where a term occurs across the Tipiṭaka and its commentaries, reading the Pali source, comparing translations, and producing cited scholarly writing. Trigger whenever the user asks to look up a Pali word or concept, find passages about a topic in the canon, analyse how a term is used, compare mūla / aṭṭhakathā / ṭīkā, check a translation against the Pali, or write a paper or summary grounded in Pali sources. Do not use for writing data into WikiPali (that is the write skill)."
+---
+
+# WikiPali 研究
+
+用 WikiPali 的语料做巴利文献研究:定位 → 取证 → 展开 → 交叉验证 → 成文。
+
+命令是 `wikipali <子命令>`(插件启用时已在 PATH 上)。检索与阅读全部只读,**不需要登录**。
+
+**坐标、引用格式、文献层次、译文来源判定见 `references/conventions.md`——那是所有
+skill 共用的规矩,必须遵守。** 端点细节见 `references/api-read.md`。
+
+## 铁律
+
+1. **每一条写进正文的引用,必须带得回坐标。** 手里没有坐标的内容,一个字都不许写进
+   产出。宁可说"未找到相关段落",也不要凭印象转述。
+2. **检索前必须先展开词形**(`wikipali forms`,或给 `search --lemma`)。直接拿词典形
+   去搜会**返回 0 条且不报错**。这是本工具最容易犯的错,因为它看起来像"搜过了,没有"。
+3. **0 条结果不等于"没有材料"。** 依次怀疑:词形没展开 → 词根选错 → 范围限太窄 →
+   才是真的没有。把怀疑过程说给用户听。
+4. **本文、义注、复注不能混。** 引用时必须标明层次(见 conventions.md)。
+5. **判断依据是巴利原文,译文只作佐证。** 机器生成的译文必须显式标注。
+6. **不要把整章往上下文里灌。** 先看体量再决定取多少。
+
+## 流程
+
+### 1. 展开词形(永远的第一步)
+
+```bash
+wikipali forms parivāsa
+```
+
+输出候选词根,每个带该词根在语料中出现过的全部词形及频次、黑体数。取可能性最高的
+那个,但**要看一眼其余候选**:若目标概念同时有名词与动词两条线(`parivāsa` /
+`parivāseti`),两条都要展开。
+
+拿不准选哪个候选时:
+
+```bash
+wikipali word parivāsa          # 释义 + 形态分析,确认词根选对了
+```
+
+### 2. 看分布,再决定范围
+
+```bash
+wikipali dist --lemma parivāsa
+```
+
+输出每部书的命中数、`--book` 值和 tags,并按 `mūla` / `aṭṭhakathā` / `ṭīkā` 汇总。
+这一步决定后面所有工作的范围:
+
+- 命中集中在律藏 → 后续加 `--tags vinaya` 收窄;
+- 本文命中少而义注命中多 → 这是个**注释书概念**,论文结构要相应调整;
+- 总量太大 → 先收窄再取证,不要硬取。
+
+⚠ `dist` 数的是**词次**,`search` 数的是**段落数**,两个数不相等。方法论里别写混。
+
+### 3. 取定义:前 50 条,靠排序
+
+```bash
+wikipali search --lemma parivāsa --limit 50
+```
+
+结果按黑体加权排序,**注释书里作为词条解释的段落会自然排在前面**。从前 50 条里挑出
+讲定义和执行流程的,用来写定义部分。
+
+命中总量特别大、前 50 条噪声明显时,可以加 `--bold` 只看黑体命中来收窄——那是收窄
+手段,不是默认做法,因为不加黑体的定义段落会被它漏掉。
+
+### 4. 取案例:全量检索
+
+```bash
+wikipali search --lemma parivāsa --tags vinaya --limit 200
+```
+
+每条给出坐标、章节路径和高亮片段。**先用片段做初筛**,判断该段落属不属于目标案例
+类型,不要一上来就把每段全文取回来。
+
+### 5. 取原文
+
+```bash
+wikipali get 216:35 216:36 216:41
+```
+
+缺省取巴利原文。`--channel <uid>` 可指定别的译本(可重复给多个)。
+
+### 6. 交叉验证
+
+用 `--channel` 取缅文逐词解析(nissaya)或各家译本,核对你基于巴利原文做出的判断。
+某坐标在某 channel 下没有内容时,如实说"该译本在此处无文本",**不要拿相邻段落或
+别的译本凑**。
+
+## 上下文预算
+
+| 操作 | 默认上限 | 超了怎么办 |
+|---|---|---|
+| `search` 摘要 | 一次不超过 50 条进上下文 | 用 `--tags` / `--book` 收窄,或分页逐批归纳 |
+| `get` 取段落 | 一次不超过 20 段 | 分批,每批处理完先记下结论再取下一批 |
+
+原则:**上下文里应该留下结论和坐标,而不是原文**。取回一批材料 → 归纳出结论并记下
+支撑坐标 → 再取下一批。不要把所有原文堆着等最后一起分析。
+
+`--width` 控制每条摘要的长度,`--json` 输出原始数据(需要自己处理时用)。
+
+## 按任务类型分档
+
+同一套检索,产出的详略要看用户要什么:
+
+| 用户要的 | 产出形态 |
+|---|---|
+| 快速查询("parivāsa 什么意思"、"哪几处提到 X") | 结论 + 坐标。**不报告检索方法**,别把查词变成论文 |
+| 综述 / 分析("X 在律藏里怎么用") | 结论 + 坐标 + 一句话交代范围 |
+| 论文 / 研究报告(用户明确说要写论文、要发表、要引用) | 完整方法论:展开了哪些词形(连同频次)、检索范围、总命中词次与段落数、实读段数、黑体与非黑体分布 |
+
+判断不了属于哪一档时按中间档走,并问用户要不要完整的检索方法说明。
+
+论文档的方法论不是修辞——"检索 parivāsa 的 13 个词形,得 281 段(449 词次),分布
+于 43 部书,其中律藏本文 159 词次、义注 28、复注 102"这样一句,是读者判断你的检索
+是否穷尽的唯一依据。
+
+## 常见错误
+
+| 现象 | 真正的原因 |
+|---|---|
+| 检索 0 条 | 多半是没展开词形,用了词典形 |
+| 结果全是义注、没有本文 | 正常——注释书解释术语的密度本来就高。用 `dist` 的层次汇总确认,别当成 bug |
+| 某段落取不到译文 | 该 channel 在该段落没有内容。如实报告 |
+| 想按短语检索 | 平台的词组检索目前不可用(服务端 500)。把短语拆成词,分别展开词形再检索 |
+| `get` 报 500 | 忘了 channel。`get` 缺省会带巴利原文的 channel,若你手动传了参数要确保 channel 在内 |

+ 18 - 21
plugins/wikipali/skills/write/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: write
-description: "Use this skill to write sentences (translations, commentary) into the WikiPali sentence database over its HTTP API, from any project. Trigger whenever the user asks to upload, push, publish, sync, or save translated Pali sentences to WikiPali / 巴利文 / wikipali.org, or mentions writing to a WikiPali channel, or asks about wp.py / wp_login.py / ~/.wikipali/credentials.json. Handles login, AI-model identity tokens, channel selection, access tokens, and batched writes with attribution as the AI model rather than the human operator. Do not use for reading WikiPali data or for unrelated Laravel/API work."
+description: "Use this skill to write sentences (translations, commentary) into the WikiPali sentence database over its HTTP API, from any project. Trigger whenever the user asks to upload, push, publish, sync, or save translated Pali sentences to WikiPali / 巴利文 / wikipali.org, or mentions writing to a WikiPali channel, or asks about the wikipali CLI, wikipali-login, or ~/.wikipali/credentials.json. Handles login, AI-model identity tokens, channel selection, access tokens, and batched writes with attribution as the AI model rather than the human operator. Do not use for reading WikiPali data or for unrelated Laravel/API work."
 metadata:
   author: mint
 ---
@@ -9,22 +9,19 @@ metadata:
 
 把句子写进 WikiPali 句子库,**署名为 AI 模型身份**(`editor_uid` = 模型 uid),而不是操作者本人。
 
-两个脚本,只依赖 Python 标准库,直接跑,不要建虚拟环境:
+只依赖 Python 标准库,直接跑,不要建虚拟环境:
 
-- `scripts/wp_login.py` —— 唯一接触密码的脚本,**必须由用户本人执行**
-- `scripts/wp.py` —— 其余全部操作
+- `wikipali-login` —— 唯一接触密码的程序,**必须由用户本人在真正的终端里执行**
+- `wikipali` —— 其余全部操作
 
-下面的命令都写成 `python3 scripts/wp.py …`,其中 `scripts/` 是**本 SKILL.md 同目录下的** `scripts/`。执行前先把它换成实际路径:
+命令是 `wikipali <子命令>`(插件启用时已在 PATH 上),登录是独立的 `wikipali-login`。
 
-- 作为插件安装时:`${CLAUDE_PLUGIN_ROOT}/skills/write/scripts/wp.py`
-- 直接放在项目里时:`<skill 目录>/scripts/wp.py`
-
-拿不准就先 `ls` 确认,不要凭猜测拼路径。
+**坐标、引用格式、译文来源判定、凭据规矩见 `references/conventions.md`——那是所有 skill 共用的,必须遵守。** 端点细节见 `references/api-write.md`。
 
 ## 铁律
 
 1. **永远不要向用户索要密码,也不要代跑 `wp_login.py`。** 需要登录时,请用户**另开一个真正的终端**执行
-   `python3 <上面那个 scripts 路径>/wp_login.py`(把完整路径写给他们,别让他们自己找)。
+   `wikipali-login`(若不在 PATH 上,把插件目录下 `bin/wikipali-login` 的完整路径写给他们)。
    不要让他们用 Claude Code 的 `!` 前缀——那里没有交互式终端,密码提示无处输入;也不要建议把密码放进命令行参数或直接打在对话里。
 2. **写入前必须让用户确认。** `wp.py write` 默认会回显目标并等确认;只有用户已经明确同意本次写入时,才可以加 `-y`。
 3. **绝不打印 token 全文**(`~/.wikipali/credentials.json` 里的任何值)。脚本自己会打码,不要 `cat` 那个文件。
@@ -34,20 +31,20 @@ metadata:
 ## 首次准备
 
 ```bash
-python3 scripts/wp.py whoami          # 先看缺什么
+wikipali whoami          # 先看缺什么
 ```
 
 按缺什么补什么:
 
 ```bash
 # 1) 登录(用户自己在另一个终端里跑,不要用 ! 前缀,也不要代跑)
-python3 scripts/wp_login.py
+wikipali-login
 
 # 2) 建立模型身份并取 token;--name 必须是你自己的模型标识
-python3 scripts/wp.py ensure-model --name claude-opus-5
+wikipali ensure-model --name claude-opus-5
 
 # 3) 看有哪些可写的 channel
-python3 scripts/wp.py channels
+wikipali channels
 ```
 
 `--name` 决定句子的作者署名,**不要冒用别的模型的名字**。同名记录已存在时会直接复用(幂等)。
@@ -69,8 +66,8 @@ python3 scripts/wp.py channels
 或直接是句子数组(此时用 `--channel` 指定目标)。`content_type` 可省略,默认 `markdown`;`channel_uid` 可以逐句给,用于跨 channel 批量写。
 
 ```bash
-python3 scripts/wp.py write sentences.json --channel <uid或名字片段> --dry-run   # 先看回显
-python3 scripts/wp.py write sentences.json --channel <uid或名字片段>            # 再真写
+wikipali write sentences.json --channel <uid或名字片段> --dry-run   # 先看回显
+wikipali write sentences.json --channel <uid或名字片段>            # 再真写
 ```
 
 `write` 会自动完成:解析校验 → 确定 channel → 回显确认 → 按需签发/复用 access token → 每 50 条一批提交 → 核对 `count` 并报告漏写的句子。
@@ -82,9 +79,9 @@ python3 scripts/wp.py write sentences.json --channel <uid或名字片段>
 四个线上地址共享同一个数据库和密钥,凭据通用;`www` 是稳定版、`next` 是最新版代码,**不是**不同的数据环境。
 
 ```bash
-python3 scripts/wp.py endpoint            # 列出并标出当前
-python3 scripts/wp.py endpoint next       # 改默认(唯一会写回凭据的方式)
-python3 scripts/wp.py --api next write …  # 只影响这一次调用
+wikipali endpoint            # 列出并标出当前
+wikipali endpoint next       # 改默认(唯一会写回凭据的方式)
+wikipali --api next write …  # 只影响这一次调用
 ```
 
 新端点在稳定版上返回 404 是「代码版本还没到」,不是「资源不存在」。
@@ -102,9 +99,9 @@ python3 scripts/wp.py --api next write …  # 只影响这一次调用
 凭据泄漏时撤销模型的全部 token:
 
 ```bash
-python3 scripts/wp.py revoke
+wikipali revoke
 ```
 
 ## 更多
 
-端点字段、返回形状与各处陷阱见 `references/api.md`。若脚本行为与该文件对不上,多半是这份副本过期了——插件用户跑 `/plugin update`,手工安装的用户重新装一遍。
+端点字段、返回形状与各处陷阱见 `references/api-write.md`。若脚本行为与该文件对不上,多半是这份副本过期了——插件用户跑 `/plugin update`,手工安装的用户重新装一遍。

+ 0 - 997
plugins/wikipali/skills/write/scripts/wp.py

@@ -1,997 +0,0 @@
-#!/usr/bin/env python3
-"""WikiPali 写入客户端。
-
-以「AI 模型身份」把句子写入 WikiPali 句子库。子命令:
-
-    endpoint     查看 / 切换 API 地址
-    whoami       显示当前凭据状态
-    ensure-model 幂等地建立模型记录并取模型身份 token
-    revoke       撤销该模型已签出的全部 token
-    channels     列出当前账号可编辑的 channel
-    grant        为某个 channel 签发 access token 并缓存
-    write        写入句子(分批 + 确认 + count 核对)
-
-约束:只用 Python 标准库,目录自包含,可整体复制到任意项目。
-密码只由 wp_login.py 接触,本脚本永不读密码。
-"""
-
-import argparse
-import base64
-import json
-import os
-import stat
-import sys
-import time
-import urllib.error
-import urllib.parse
-import urllib.request
-from datetime import datetime, timezone
-
-# ---------------------------------------------------------------------------
-# 站点清单
-# ---------------------------------------------------------------------------
-# 四个线上地址共享同一个数据库和同一把 jwt 密钥,凭据完全通用;
-# .org / .cc 是地区可达性,www / next 是代码版本(不是数据环境)。
-# 开发机是另一个库、另一把密钥,故单独一桶,且永不作为自动 fallback 目标。
-
-SITES = [
-    {"key": "www", "url": "https://www.wikipali.org/api", "version": "稳定版", "domain": ".org"},
-    {"key": "www.cc", "url": "https://www.wikipali.cc/api", "version": "稳定版", "domain": ".cc"},
-    {"key": "next", "url": "https://next.wikipali.org/api", "version": "最新版", "domain": ".org"},
-    {"key": "next.cc", "url": "https://next.wikipali.cc/api", "version": "最新版", "domain": ".cc"},
-    {"key": "local", "url": "http://127.0.0.1:8000/api", "version": "开发机", "domain": "本机"},
-]
-
-ONLINE_URLS = [s["url"] for s in SITES if s["key"] != "local"]
-LOCAL_URL = SITES[-1]["url"]
-DEFAULT_API_URL = SITES[0]["url"]
-
-CREDS_DIR = os.path.join(os.path.expanduser("~"), ".wikipali")
-CREDS_PATH = os.path.join(CREDS_DIR, "credentials.json")
-
-# access token 剩余不足这么多秒就重新签发,避免写到一半过期
-TOKEN_REFRESH_MARGIN = 3600
-
-DEFAULT_TIMEOUT = 30
-WRITE_TIMEOUT = 120
-DEFAULT_BATCH = 50
-
-
-# ---------------------------------------------------------------------------
-# 错误类型
-# ---------------------------------------------------------------------------
-
-
-class WpError(Exception):
-    """面向用户的错误:main() 捕获后只打印 message,不打印堆栈。"""
-
-
-class ApiError(WpError):
-    def __init__(self, status, message, url=None, body=None):
-        self.status = status
-        self.url = url
-        self.body = body
-        super().__init__(message)
-
-
-# ---------------------------------------------------------------------------
-# 凭据文件
-# ---------------------------------------------------------------------------
-
-
-def load_creds():
-    if not os.path.exists(CREDS_PATH):
-        return {"current": "online"}
-    try:
-        with open(CREDS_PATH, "r", encoding="utf-8") as fh:
-            data = json.load(fh)
-    except (OSError, ValueError) as exc:
-        raise WpError(f"凭据文件无法读取({CREDS_PATH}):{exc}")
-    if not isinstance(data, dict):
-        raise WpError(f"凭据文件格式不对({CREDS_PATH}),应为 JSON 对象")
-    data.setdefault("current", "online")
-    return data
-
-
-def save_creds(creds):
-    os.makedirs(CREDS_DIR, mode=0o700, exist_ok=True)
-    tmp = CREDS_PATH + ".tmp"
-    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
-    fd = os.open(tmp, flags, 0o600)
-    try:
-        with os.fdopen(fd, "w", encoding="utf-8") as fh:
-            json.dump(creds, fh, ensure_ascii=False, indent=2)
-            fh.write("\n")
-    except Exception:
-        os.unlink(tmp)
-        raise
-    os.replace(tmp, CREDS_PATH)
-    os.chmod(CREDS_PATH, stat.S_IRUSR | stat.S_IWUSR)
-
-
-def bucket_name_for(api_url):
-    """凭据桶名。线上四地址共用 online 桶;开发机 local;其余地址自成一桶。"""
-    if api_url in ONLINE_URLS:
-        return "online"
-    if api_url == LOCAL_URL:
-        return "local"
-    return "site:" + api_url
-
-
-def get_bucket(creds, name, api_url=None):
-    bucket = creds.setdefault(name, {})
-    bucket.setdefault("api_url", api_url or (DEFAULT_API_URL if name == "online" else LOCAL_URL))
-    bucket.setdefault("user", {})
-    bucket.setdefault("model", {})
-    bucket.setdefault("access_tokens", {})
-    return bucket
-
-
-def normalize_api_url(url):
-    url = url.rstrip("/")
-    parsed = urllib.parse.urlparse(url)
-    if parsed.scheme not in ("http", "https"):
-        raise WpError(f"API 地址必须以 http:// 或 https:// 开头:{url}")
-    host = (parsed.hostname or "").lower()
-    if parsed.scheme == "http" and host not in ("127.0.0.1", "localhost", "::1"):
-        raise WpError(f"只有 127.0.0.1 / localhost 允许用 http://,其余必须 https://:{url}")
-    return url
-
-
-def resolve_api_url(cli_api, creds):
-    """地址来源优先级:--api > 环境变量 > 凭据文件 > 内置默认。
-
-    前两者是一次性覆盖,不写回凭据文件——否则「上周试了一次 next」会一直粘着。
-    """
-    if cli_api:
-        return normalize_api_url(expand_site_alias(cli_api)), "cli"
-    env = os.environ.get("WIKIPALI_API_URL")
-    if env:
-        return normalize_api_url(expand_site_alias(env)), "env"
-    current = creds.get("current", "online")
-    bucket = creds.get(current)
-    if isinstance(bucket, dict) and bucket.get("api_url"):
-        return normalize_api_url(bucket["api_url"]), "creds"
-    return DEFAULT_API_URL, "default"
-
-
-def expand_site_alias(value):
-    """把序号 / 简称展开成完整 url;已是 url 则原样返回。"""
-    value = value.strip()
-    if value.isdigit():
-        idx = int(value) - 1
-        if 0 <= idx < len(SITES):
-            return SITES[idx]["url"]
-        raise WpError(f"站点序号超出范围:{value}(可选 1-{len(SITES)})")
-    for site in SITES:
-        if value == site["key"]:
-            return site["url"]
-    if "://" in value:
-        return value
-    raise WpError(
-        f"无法识别的站点:{value}。可用简称:" + " / ".join(s["key"] for s in SITES) + ",或直接给完整 url"
-    )
-
-
-def site_label(api_url):
-    for site in SITES:
-        if site["url"] == api_url:
-            return f"{site['version']} · {site['domain']}"
-    return "自定义地址"
-
-
-# ---------------------------------------------------------------------------
-# HTTP
-# ---------------------------------------------------------------------------
-
-
-def mask(token):
-    if not token:
-        return "(无)"
-    if len(token) <= 16:
-        return token[:4] + "…"
-    return token[:8] + "…" + token[-4:]
-
-
-def jwt_payload(token):
-    """不验签地读出 JWT payload,仅用于显示有效期。"""
-    try:
-        part = token.split(".")[1]
-        part += "=" * (-len(part) % 4)
-        return json.loads(base64.urlsafe_b64decode(part.encode("ascii")))
-    except Exception:
-        return {}
-
-
-def token_expiry(token):
-    exp = jwt_payload(token).get("exp")
-    return int(exp) if isinstance(exp, (int, float)) else None
-
-
-def fmt_ts(ts):
-    if not ts:
-        return "未知"
-    return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
-
-
-def note(msg):
-    print(msg, file=sys.stderr)
-
-
-def http_json(api_url, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
-    """发一个 JSON 请求,返回解析后的响应体(dict)。
-
-    网络层失败抛 urllib 的异常(由 Client 决定是否 fallback);
-    HTTP 层失败抛 ApiError,带上服务端 message。
-    """
-    url = api_url + "/" + path.lstrip("/")
-    if query:
-        url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
-    data = None
-    headers = {"Accept": "application/json", "User-Agent": "wikipali-write-skill"}
-    if body is not None:
-        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
-        headers["Content-Type"] = "application/json"
-    if token:
-        headers["Authorization"] = "Bearer " + token
-    req = urllib.request.Request(url, data=data, headers=headers, method=method)
-    try:
-        with urllib.request.urlopen(req, timeout=timeout) as resp:
-            raw = resp.read().decode("utf-8", "replace")
-            status = resp.status
-    except urllib.error.HTTPError as exc:
-        raw = exc.read().decode("utf-8", "replace")
-        status = exc.code
-        payload = safe_json(raw)
-        message = payload.get("message") if isinstance(payload, dict) else None
-        raise ApiError(status, message or f"HTTP {status}", url=url, body=payload or raw)
-    payload = safe_json(raw)
-    if not isinstance(payload, dict):
-        raise ApiError(status, f"响应不是 JSON:{raw[:200]}", url=url, body=raw)
-    if not payload.get("ok", False):
-        raise ApiError(status, payload.get("message") or "请求失败", url=url, body=payload)
-    return payload.get("data")
-
-
-def safe_json(raw):
-    try:
-        return json.loads(raw)
-    except ValueError:
-        return None
-
-
-class Client:
-    """按站点收发请求,并在线上地址之间做出声的 fallback。"""
-
-    def __init__(self, api_url, source, creds, allow_fallback=True):
-        self.api_url = api_url
-        self.source = source
-        self.creds = creds
-        self.bucket_name = bucket_name_for(api_url)
-        self.bucket = get_bucket(creds, self.bucket_name, api_url)
-        self.allow_fallback = allow_fallback and api_url in ONLINE_URLS
-
-    # -- 凭据 ---------------------------------------------------------------
-
-    @property
-    def user_token(self):
-        token = (self.bucket.get("user") or {}).get("token")
-        if not token:
-            raise WpError(
-                "尚未登录。请自己执行(Claude Code 里用 ! 前缀):\n"
-                "    ! python3 " + os.path.join(os.path.dirname(os.path.abspath(__file__)), "wp_login.py")
-            )
-        return token
-
-    @property
-    def model(self):
-        model = self.bucket.get("model") or {}
-        if not model.get("token"):
-            raise WpError("尚未取得模型身份 token。请先跑:python3 wp.py ensure-model --name <模型名>")
-        return model
-
-    def save(self):
-        save_creds(self.creds)
-
-    # -- 请求 ---------------------------------------------------------------
-
-    def fallback_order(self):
-        """同版本的另一域名 → 另一版本的同域名 → 其余。绝不含 local。"""
-        cur = next((s for s in SITES if s["url"] == self.api_url), None)
-        if not cur:
-            return []
-        others = [s for s in SITES if s["key"] != "local" and s["url"] != self.api_url]
-        others.sort(
-            key=lambda s: (
-                0 if s["version"] == cur["version"] else 1,
-                0 if s["domain"] == cur["domain"] else 1,
-            )
-        )
-        return [s["url"] for s in others]
-
-    def call(self, method, path, token=None, body=None, query=None, timeout=DEFAULT_TIMEOUT):
-        urls = [self.api_url] + (self.fallback_order() if self.allow_fallback else [])
-        last = None
-        for idx, url in enumerate(urls):
-            try:
-                data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
-            except (urllib.error.URLError, TimeoutError, OSError) as exc:
-                # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
-                last = exc
-                reason = getattr(exc, "reason", exc)
-                if idx + 1 < len(urls):
-                    note(f"⚠ {url} 连接失败({reason}),改用 {urls[idx + 1]}")
-                continue
-            if url != self.api_url:
-                # fallback 成功后本次会话都用它,但不写回凭据文件
-                note(f"⚠ 本次请求实际发往 {url}({site_label(url)})")
-                self.api_url = url
-            return data
-        raise WpError(f"所有可用站点都连不上,最后一次错误:{last}")
-
-    def api_note(self):
-        src = {"cli": "--api", "env": "环境变量", "creds": "凭据文件", "default": "内置默认"}[self.source]
-        return f"{self.api_url}({site_label(self.api_url)},来源:{src})"
-
-
-def make_client(args, allow_fallback=True):
-    creds = load_creds()
-    api_url, source = resolve_api_url(getattr(args, "api", None), creds)
-    return Client(api_url, source, creds, allow_fallback=allow_fallback)
-
-
-def explain_api_error(exc, what):
-    """把 HTTP 状态翻译成对操作者有意义的话(见 references/api.md 的错误约定)。"""
-    if exc.status == 401:
-        return WpError(
-            f"{what}:401 凭据失效或已被撤销。\n"
-            "  · 用户 token 失效 → 重新登录:! python3 scripts/wp_login.py\n"
-            "  · 模型 token 失效或被撤销 → 重跑:python3 wp.py ensure-model\n"
-            "  不要自动重试。"
-        )
-    if exc.status == 403:
-        return WpError(f"{what}:403 无权限(不是 channel 的 owner/协作者,或不是模型 owner 本人)。")
-    if exc.status == 404:
-        return WpError(
-            f"{what}:404。若这是较新的端点,可能是当前站点跑的是稳定版代码、端点尚未上线;\n"
-            "  可切到最新版试试:python3 wp.py endpoint next\n"
-            "  否则才是资源真的不存在。"
-        )
-    if exc.status == 409:
-        return WpError(f"{what}:409 同名记录已存在。")
-    if exc.status == 422:
-        return WpError(f"{what}:422 参数校验失败——{exc}")
-    return WpError(f"{what}:HTTP {exc.status} {exc}")
-
-
-# ---------------------------------------------------------------------------
-# 子命令:endpoint
-# ---------------------------------------------------------------------------
-
-
-def cmd_endpoint(args):
-    creds = load_creds()
-    current_url, source = resolve_api_url(getattr(args, "api", None), creds)
-
-    if not args.target:
-        for idx, site in enumerate(SITES, 1):
-            mark = "  ← 当前" if site["url"] == current_url else ""
-            print(f"  {idx}) {site['url']:<32} {site['version']} · {site['domain']}{mark}")
-        if source in ("cli", "env"):
-            src = "--api" if source == "cli" else "WIKIPALI_API_URL"
-            note(f"注意:当前地址来自 {src},是一次性覆盖,未写入凭据文件。")
-        if current_url not in [s["url"] for s in SITES]:
-            print(f"  *) {current_url:<32} 自定义地址  ← 当前")
-        print("\n切换:python3 wp.py endpoint <序号|www|www.cc|next|next.cc|local|完整url>")
-        return 0
-
-    url = normalize_api_url(expand_site_alias(args.target))
-    name = bucket_name_for(url)
-    bucket = get_bucket(creds, name, url)
-    bucket["api_url"] = url
-    creds["current"] = name
-    save_creds(creds)
-    print(f"已切换到 {url}({site_label(url)})")
-    if name != "online":
-        note("提示:该地址与线上四站不共用数据库/密钥,凭据是独立的一桶,可能需要重新登录。")
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# 子命令:whoami
-# ---------------------------------------------------------------------------
-
-
-def cmd_whoami(args):
-    client = make_client(args)
-    print(f"API      : {client.api_note()}")
-    print(f"凭据文件 : {CREDS_PATH}(桶:{client.bucket_name})")
-
-    user = client.bucket.get("user") or {}
-    if user.get("token"):
-        exp = token_expiry(user["token"])
-        expired = exp is not None and exp < time.time()
-        print(f"用户     : {user.get('username', '?')}  uid={user.get('uid', '?')}")
-        print(f"           token {mask(user['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
-    else:
-        print("用户     : 未登录(! python3 scripts/wp_login.py)")
-
-    model = client.bucket.get("model") or {}
-    if model.get("token"):
-        exp = token_expiry(model["token"])
-        expired = exp is not None and exp < time.time()
-        print(f"模型     : {model.get('name', '?')}  uid={model.get('uid', '?')}")
-        print(f"           token {mask(model['token'])}  到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
-    else:
-        print("模型     : 未建立(python3 wp.py ensure-model --name <模型名>)")
-
-    tokens = client.bucket.get("access_tokens") or {}
-    if tokens:
-        print("access token:")
-        for uid, item in tokens.items():
-            exp = item.get("exp") or token_expiry(item.get("token", ""))
-            expired = exp is not None and exp < time.time()
-            book = item.get("book", 0)
-            scope = "全部 book" if book == 0 else f"book {book}"
-            name = item.get("channel_name") or ""
-            print(f"  {uid[:8]}… {name:<24} {scope:<10} 到期 {fmt_ts(exp)}{'  ⚠ 已过期' if expired else ''}")
-    else:
-        print("access token:无(python3 wp.py grant <channel>)")
-
-    if args.check:
-        try:
-            data = client.call("GET", "v2/auth/current", token=client.user_token)
-        except ApiError as exc:
-            raise explain_api_error(exc, "校验用户 token")
-        print(f"\n服务端确认:{data.get('nickName')} / realName={data.get('realName')}(studio_name 用它)")
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# 子命令:ensure-model
-# ---------------------------------------------------------------------------
-
-
-def cmd_ensure_model(args):
-    client = make_client(args)
-    token = client.user_token
-
-    name = args.name or (client.bucket.get("model") or {}).get("name") or os.environ.get("WIKIPALI_MODEL_NAME")
-    if not name:
-        raise WpError(
-            "必须指定模型名:--name <模型标识>(如 claude-opus-5)。\n"
-            "该名字会成为句子的作者署名,不要用别的模型的名字。"
-        )
-
-    try:
-        current = client.call("GET", "v2/auth/current", token=token)
-    except ApiError as exc:
-        raise explain_api_error(exc, "取当前用户信息")
-    studio_name = current.get("realName")
-    if not studio_name:
-        raise WpError("服务端没有返回 realName,无法确定 studio_name。")
-
-    # 1) 按 studio + keyword 查,keyword 是模糊匹配,客户端自己做精确比对
-    try:
-        listed = client.call(
-            "GET", "v2/ai-model", token=token,
-            query={"view": "studio", "name": studio_name, "keyword": name},
-        )
-    except ApiError as exc:
-        raise explain_api_error(exc, "查询模型列表")
-    rows = (listed or {}).get("rows") or []
-    found = next((r for r in rows if r.get("name") == name), None)
-
-    if found:
-        print(f"已存在模型记录:{name}  uid={found['uid']}")
-    else:
-        body = {"name": name, "studio_name": studio_name, "privacy": args.privacy}
-        for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
-            if value is not None:
-                body[field] = value
-        try:
-            found = client.call("POST", "v2/ai-model", token=token, body=body)
-            print(f"已创建模型记录:{name}  uid={found['uid']}")
-        except ApiError as exc:
-            if exc.status != 409:
-                raise explain_api_error(exc, "创建模型记录")
-            # 并发或模糊匹配漏网:重查一次
-            listed = client.call(
-                "GET", "v2/ai-model", token=token,
-                query={"view": "studio", "name": studio_name, "keyword": name},
-            )
-            rows = (listed or {}).get("rows") or []
-            found = next((r for r in rows if r.get("name") == name), None)
-            if not found:
-                raise WpError(f"服务端说 {name} 已存在(409),但列表里查不到,无法继续。")
-            print(f"已存在模型记录:{name}  uid={found['uid']}")
-
-    # 2) 增量补字段(update 是增量的,未提交的字段保持原值)
-    patch = {}
-    for field, value in (("model", args.model), ("url", args.url), ("description", args.description)):
-        if value is not None and found.get(field) != value:
-            patch[field] = value
-    if args.privacy and found.get("privacy") != args.privacy:
-        patch["privacy"] = args.privacy
-    if patch:
-        try:
-            found = client.call("PUT", f"v2/ai-model/{found['uid']}", token=token, body=patch)
-            print(f"已更新字段:{', '.join(patch)}")
-        except ApiError as exc:
-            raise explain_api_error(exc, "更新模型记录")
-
-    # 3) 取模型身份 token
-    try:
-        issued = client.call("GET", f"v2/ai-model-token/{found['uid']}", token=token)
-    except ApiError as exc:
-        raise explain_api_error(exc, "签发模型身份 token")
-
-    client.bucket["model"] = {
-        "uid": issued["uid"],
-        "name": issued["name"],
-        "token": issued["token"],
-        "issued_at": iso_now(),
-    }
-    client.save()
-    exp = token_expiry(issued["token"])
-    print(f"模型身份 token 已缓存:{mask(issued['token'])}  到期 {fmt_ts(exp)}")
-    print(f"写入的句子将署名为该模型(editor_uid={issued['uid']})。")
-    return 0
-
-
-def cmd_revoke(args):
-    client = make_client(args)
-    model = client.bucket.get("model") or {}
-    uid = args.uid or model.get("uid")
-    if not uid:
-        raise WpError("没有可撤销的模型:请给 --uid <模型 uid>,或先跑 ensure-model。")
-    if not args.yes and not confirm(f"将撤销模型 {model.get('name', uid)} 已签出的全部 token,继续?"):
-        print("已取消。")
-        return 1
-    try:
-        data = client.call("DELETE", f"v2/ai-model-token/{uid}", token=client.user_token)
-    except ApiError as exc:
-        raise explain_api_error(exc, "撤销模型 token")
-    if model.get("uid") == uid:
-        client.bucket["model"] = {"uid": uid, "name": model.get("name")}
-        client.save()
-    print(f"已撤销 {data.get('name')} 的全部 token(token_version={data.get('token_version')})。")
-    print("本地缓存的模型 token 已清除,需要写入时请重跑 ensure-model。")
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# 子命令:channels
-# ---------------------------------------------------------------------------
-
-
-def fetch_channels(client, search=None):
-    try:
-        data = client.call(
-            "GET", "v2/channel", token=client.user_token,
-            query={"view": "user-edit", "order": "updated_at", "dir": "desc", "limit": 200, "search": search},
-        )
-    except ApiError as exc:
-        raise explain_api_error(exc, "获取可编辑 channel 列表")
-    return (data or {}).get("rows") or []
-
-
-def cmd_channels(args):
-    client = make_client(args)
-    rows = fetch_channels(client, args.search)
-    if args.json:
-        print(json.dumps(rows, ensure_ascii=False, indent=2))
-        return 0
-    if not rows:
-        print("当前账号没有任何可编辑的 channel。")
-        return 1
-    print(f"可编辑 channel({len(rows)} 个,按更新时间倒序):")
-    for idx, ch in enumerate(rows, 1):
-        print(
-            f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} "
-            f"{ch.get('uid', '')[:8]}…  {ch.get('role', '')}"
-        )
-    return 0
-
-
-def pick_channel(client, given, interactive=True):
-    """返回 (uid, name)。given 可以是 uid、序号或名字片段;为空则交互选择。"""
-    rows = fetch_channels(client)
-    if not rows:
-        raise WpError("当前账号没有任何可编辑的 channel,无法继续。")
-
-    if given:
-        for ch in rows:
-            if ch.get("uid") == given:
-                return ch["uid"], ch.get("name")
-        if given.isdigit() and 1 <= int(given) <= len(rows):
-            ch = rows[int(given) - 1]
-            return ch["uid"], ch.get("name")
-        matched = [c for c in rows if given.lower() in (c.get("name") or "").lower()]
-        if len(matched) == 1:
-            return matched[0]["uid"], matched[0].get("name")
-        if len(matched) > 1:
-            names = ", ".join(c.get("name", "") for c in matched[:5])
-            raise WpError(f"「{given}」匹配到多个 channel:{names}…… 请给完整 uid。")
-        # 不在可编辑列表里的 uid:直接用,但回显不出名字
-        if len(given) >= 32:
-            note(f"⚠ {given} 不在可编辑列表中,仍按 uid 使用——签发 access token 时可能返回 count: 0。")
-            return given, None
-        raise WpError(f"找不到 channel:{given}")
-
-    if not (interactive and sys.stdin.isatty()):
-        raise WpError("未指定 channel,且当前不是交互式终端。请先跑 `wp.py channels` 再用 --channel 指定。")
-
-    print("可编辑 channel:")
-    for idx, ch in enumerate(rows, 1):
-        print(f"  {idx:>2}) {ch.get('name', '')[:32]:<34} {str(ch.get('lang', '')):<6} {ch.get('uid', '')[:8]}…")
-    raw = input("选择序号:").strip()
-    if not raw.isdigit() or not (1 <= int(raw) <= len(rows)):
-        raise WpError("输入无效。")
-    ch = rows[int(raw) - 1]
-    return ch["uid"], ch.get("name")
-
-
-# ---------------------------------------------------------------------------
-# 子命令:grant
-# ---------------------------------------------------------------------------
-
-
-def cached_access_token(client, channel_uid, book):
-    item = (client.bucket.get("access_tokens") or {}).get(channel_uid)
-    if not item or not item.get("token"):
-        return None
-    # book 0 是「不限 book」,能覆盖任何请求;否则必须完全一致
-    if item.get("book", 0) != 0 and item.get("book") != book:
-        return None
-    exp = item.get("exp") or token_expiry(item["token"])
-    if exp and exp - time.time() < TOKEN_REFRESH_MARGIN:
-        return None
-    return item
-
-
-def grant_access_token(client, channel_uid, channel_name, book, force=False):
-    if not force:
-        cached = cached_access_token(client, channel_uid, book)
-        if cached:
-            return cached
-
-    # book 必须是整数:服务端用 !== 严格比较,"1" !== 1 恒真会导致鉴权失败
-    payload = [{"res_type": "channel", "res_id": channel_uid, "power": "edit", "book": int(book)}]
-    try:
-        data = client.call("POST", "v2/access-token", token=client.user_token, body={"payload": payload})
-    except ApiError as exc:
-        raise explain_api_error(exc, "签发 access token")
-    rows = (data or {}).get("rows") or []
-    if not rows:
-        # 无权时服务端静默跳过该条,rows 为空——等同 403,绝不能继续写
-        raise WpError(
-            f"签发 access token 返回 count: 0,说明当前账号对 channel {channel_uid} 没有编辑权。\n"
-            "不要继续写入。请确认选对了 channel,或让 owner 授予 ≥ editor 权限。"
-        )
-    row = rows[0]
-    item = {
-        "token": row["token"],
-        "book": int(book),
-        "exp": (row.get("payload") or {}).get("exp"),
-        "granted_at": iso_now(),
-    }
-    if channel_name:
-        item["channel_name"] = channel_name
-    client.bucket.setdefault("access_tokens", {})[channel_uid] = item
-    client.save()
-    return item
-
-
-def cmd_grant(args):
-    client = make_client(args)
-    uid, name = pick_channel(client, args.channel)
-    item = grant_access_token(client, uid, name, args.book, force=args.force)
-    scope = "全部 book" if item["book"] == 0 else f"book {item['book']}"
-    print(f"channel : {name or '(未知)'}  {uid}")
-    print(f"范围    : {scope}")
-    print(f"token   : {mask(item['token'])}  到期 {fmt_ts(item.get('exp'))}")
-    return 0
-
-
-# ---------------------------------------------------------------------------
-# 子命令:write
-# ---------------------------------------------------------------------------
-
-SENT_REQUIRED = ("book_id", "paragraph", "word_start", "word_end", "content")
-
-
-def load_sentences(args):
-    if args.file == "-":
-        raw = sys.stdin.read()
-    else:
-        try:
-            with open(args.file, "r", encoding="utf-8") as fh:
-                raw = fh.read()
-        except OSError as exc:
-            raise WpError(f"读不了输入文件:{exc}")
-    try:
-        data = json.loads(raw)
-    except ValueError as exc:
-        raise WpError(f"输入不是合法 JSON:{exc}")
-
-    default_channel = None
-    if isinstance(data, dict):
-        default_channel = data.get("channel_uid") or data.get("channel")
-        data = data.get("sentences")
-    if not isinstance(data, list) or not data:
-        raise WpError('输入必须是句子数组,或 {"channel_uid": ..., "sentences": [...]},且非空。')
-    return data, default_channel
-
-
-def normalize_sentences(rows, channel_uid, default_content_type):
-    out = []
-    for idx, row in enumerate(rows):
-        if not isinstance(row, dict):
-            raise WpError(f"第 {idx + 1} 条不是对象。")
-        missing = [f for f in SENT_REQUIRED if row.get(f) is None]
-        if missing:
-            raise WpError(f"第 {idx + 1} 条缺字段:{', '.join(missing)}")
-        try:
-            sent = {
-                "book_id": int(row["book_id"]),
-                "paragraph": int(row["paragraph"]),
-                "word_start": int(row["word_start"]),
-                "word_end": int(row["word_end"]),
-                "content": str(row["content"]),
-                "content_type": row.get("content_type") or default_content_type,
-                "channel_uid": row.get("channel_uid") or channel_uid,
-            }
-        except (TypeError, ValueError) as exc:
-            raise WpError(f"第 {idx + 1} 条字段类型不对:{exc}")
-        if not sent["channel_uid"]:
-            raise WpError(f"第 {idx + 1} 条没有 channel_uid,且未通过 --channel 指定。")
-        out.append(sent)
-    return out
-
-
-def sent_key(sent):
-    return (
-        int(sent["book_id"]),
-        int(sent["paragraph"]),
-        int(sent["word_start"]),
-        int(sent["word_end"]),
-        sent["channel_uid"],
-    )
-
-
-def row_key(row):
-    channel = row.get("channel") or {}
-    return (
-        int(row.get("book", -1)),
-        int(row.get("paragraph", -1)),
-        int(row.get("word_start", -1)),
-        int(row.get("word_end", -1)),
-        channel.get("uid"),
-    )
-
-
-def confirm(question):
-    if not sys.stdin.isatty():
-        return False
-    answer = input(f"{question} [y/N] ").strip().lower()
-    return answer in ("y", "yes")
-
-
-def iso_now():
-    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
-
-
-def cmd_write(args):
-    client = make_client(args)
-    # 先确认凭据齐备再解析输入:缺 token 时不该让用户看半张回显
-    client.user_token
-    model = client.model
-    rows, file_channel = load_sentences(args)
-
-    channel_hint = args.channel or file_channel
-    uid = name = None
-    if channel_hint or not any(r.get("channel_uid") for r in rows if isinstance(r, dict)):
-        uid, name = pick_channel(client, channel_hint)
-    sentences = normalize_sentences(rows, uid, args.content_type)
-
-    channels = sorted({s["channel_uid"] for s in sentences})
-    books = sorted({s["book_id"] for s in sentences})
-    names = {uid: name} if uid else {}
-    for cuid in channels:
-        if cuid not in names:
-            names[cuid] = channel_display_name(client, cuid)
-
-    # 写入前的确认:出问题时必须知道是哪一版代码、写进了哪个 channel
-    print("=" * 72)
-    print(f"API      : {client.api_note()}")
-    for cuid in channels:
-        print(f"channel  : {names.get(cuid) or '(未知)'}  {cuid}")
-    print(f"book     : {', '.join(str(b) for b in books)}")
-    print(f"模型身份 : {model.get('name')}  uid={model.get('uid')}")
-    print(f"句子数   : {len(sentences)}(每批 {args.batch})")
-    print("-" * 72)
-    for sent in sentences[: args.preview]:
-        summary = sent["content"].replace("\n", " ")
-        if len(summary) > 50:
-            summary = summary[:50] + "…"
-        print(f"  {sent['book_id']}-{sent['paragraph']}-{sent['word_start']}-{sent['word_end']}  {summary}")
-    if len(sentences) > args.preview:
-        print(f"  …… 其余 {len(sentences) - args.preview} 条")
-    print("-" * 72)
-    print("⚠ 相同位置(book/paragraph/word_start/word_end/channel)的已有句子将被覆盖。")
-    print("=" * 72)
-
-    if args.dry_run:
-        print("--dry-run:未发送任何请求。")
-        return 0
-    if not args.yes and not confirm("确认写入?"):
-        print("已取消,未写入任何内容。")
-        return 1
-
-    # 每个 channel 一张 access token(缓存命中就不重签)
-    tokens = {}
-    for cuid in channels:
-        book_scope = 0 if len(books) > 1 else books[0]
-        if args.book is not None:
-            book_scope = args.book
-        item = grant_access_token(client, cuid, names.get(cuid), book_scope)
-        tokens[cuid] = item["token"]
-
-    written = {}
-    failed = []
-    model_token = model["token"]
-    for start in range(0, len(sentences), args.batch):
-        batch = sentences[start : start + args.batch]
-        body = {
-            "sentences": [
-                {
-                    "book_id": s["book_id"],
-                    "paragraph": s["paragraph"],
-                    "word_start": s["word_start"],
-                    "word_end": s["word_end"],
-                    "channel_uid": s["channel_uid"],
-                    "content": s["content"],
-                    "content_type": s["content_type"],
-                    "access_token": tokens[s["channel_uid"]],
-                }
-                for s in batch
-            ]
-        }
-        try:
-            data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
-        except ApiError as exc:
-            if exc.status != 401:
-                raise explain_api_error(exc, "写入句子")
-            # 模型 token 过期或被撤销:重取一次再试,仍失败才提示重新登录
-            model_token = refresh_model_token(client)
-            try:
-                data = client.call("POST", "v2/sentence", token=model_token, body=body, timeout=WRITE_TIMEOUT)
-            except ApiError as retry_exc:
-                raise explain_api_error(retry_exc, "写入句子(已重签模型 token 后重试)")
-        returned = (data or {}).get("rows") or []
-        for row in returned:
-            written[row_key(row)] = row
-        got = len(returned)
-        print(f"批次 {start // args.batch + 1}: 提交 {len(batch)},服务端确认 {got}")
-        if got < len(batch):
-            # HTTP 200 不等于全部写入:逐句鉴权失败是静默 continue 掉的
-            for s in batch:
-                if sent_key(s) not in written:
-                    failed.append(s)
-
-    print("-" * 72)
-    print(f"合计提交 {len(sentences)} 条,确认写入 {len(written)} 条。")
-    sample = next(iter(written.values()), None)
-    if sample:
-        editor = (sample.get("editor") or {}).get("nickName") or (sample.get("editor") or {}).get("name")
-        print(f"署名核对:第一条的 editor = {editor}")
-    if failed:
-        print(f"⚠ 有 {len(failed)} 条未写入(服务端逐句鉴权失败会静默跳过):")
-        for s in failed[:10]:
-            print(f"  {s['book_id']}-{s['paragraph']}-{s['word_start']}-{s['word_end']}  channel={s['channel_uid'][:8]}…")
-        if len(failed) > 10:
-            print(f"  …… 其余 {len(failed) - 10} 条")
-        return 1
-    return 0
-
-
-def channel_display_name(client, uid):
-    try:
-        data = client.call("GET", f"v2/channel/{uid}", token=client.user_token)
-    except (ApiError, WpError):
-        return None
-    if isinstance(data, dict):
-        return data.get("name")
-    return None
-
-
-def refresh_model_token(client):
-    note("⚠ 模型 token 被拒(过期或已撤销),正在重新签发……")
-    model = client.bucket.get("model") or {}
-    if not model.get("uid"):
-        raise WpError("缓存里没有模型 uid,无法重签。请跑:python3 wp.py ensure-model --name <模型名>")
-    try:
-        issued = client.call("GET", f"v2/ai-model-token/{model['uid']}", token=client.user_token)
-    except ApiError as exc:
-        raise explain_api_error(exc, "重新签发模型 token")
-    model.update({"uid": issued["uid"], "name": issued["name"], "token": issued["token"], "issued_at": iso_now()})
-    client.bucket["model"] = model
-    client.save()
-    return issued["token"]
-
-
-# ---------------------------------------------------------------------------
-# CLI
-# ---------------------------------------------------------------------------
-
-
-def build_parser():
-    parser = argparse.ArgumentParser(
-        prog="wp.py",
-        description="WikiPali 写入客户端(以 AI 模型身份写入句子)",
-        formatter_class=argparse.RawDescriptionHelpFormatter,
-    )
-    parser.add_argument("--api", help="本次调用使用的 API 地址(序号/简称/完整 url),不写回凭据文件")
-    sub = parser.add_subparsers(dest="command", required=True)
-
-    p = sub.add_parser("endpoint", help="查看 / 切换 API 地址")
-    p.add_argument("target", nargs="?", help="序号、简称(www/www.cc/next/next.cc/local)或完整 url")
-    p.set_defaults(func=cmd_endpoint)
-
-    p = sub.add_parser("whoami", help="显示当前凭据状态")
-    p.add_argument("--check", action="store_true", help="额外向服务端校验用户 token")
-    p.set_defaults(func=cmd_whoami)
-
-    p = sub.add_parser("ensure-model", help="幂等地建立模型记录并取模型身份 token")
-    p.add_argument("--name", help="模型标识,如 claude-opus-5(会成为句子作者署名)")
-    p.add_argument("--model", help="底层模型 id")
-    p.add_argument("--url", dest="url", help="模型服务地址")
-    p.add_argument("--description", help="描述")
-    p.add_argument("--privacy", choices=["private", "public"], default="private")
-    p.set_defaults(func=cmd_ensure_model)
-
-    p = sub.add_parser("revoke", help="撤销该模型已签出的全部 token")
-    p.add_argument("--uid", help="模型 uid,缺省用缓存里的")
-    p.add_argument("-y", "--yes", action="store_true")
-    p.set_defaults(func=cmd_revoke)
-
-    p = sub.add_parser("channels", help="列出当前账号可编辑的 channel")
-    p.add_argument("--search", help="按名字过滤")
-    p.add_argument("--json", action="store_true", help="输出原始 JSON")
-    p.set_defaults(func=cmd_channels)
-
-    p = sub.add_parser("grant", help="为某个 channel 签发 access token 并缓存")
-    p.add_argument("channel", nargs="?", help="channel uid / 列表序号 / 名字片段;省略则交互选择")
-    p.add_argument("--book", type=int, default=0, help="限定 book,0 表示不限(默认)")
-    p.add_argument("--force", action="store_true", help="即使缓存未过期也重新签发")
-    p.set_defaults(func=cmd_grant)
-
-    p = sub.add_parser("write", help="写入句子")
-    p.add_argument("file", help="句子 JSON 文件,- 表示从 stdin 读")
-    p.add_argument("--channel", help="目标 channel(uid / 序号 / 名字片段)")
-    p.add_argument("--book", type=int, help="access token 的 book 范围,缺省按句子推断")
-    p.add_argument("--batch", type=int, default=DEFAULT_BATCH, help=f"每批条数,默认 {DEFAULT_BATCH}")
-    p.add_argument("--content-type", default="markdown")
-    p.add_argument("--preview", type=int, default=5, help="确认时预览几条")
-    p.add_argument("--dry-run", action="store_true", help="只做校验与回显,不发请求")
-    p.add_argument("-y", "--yes", action="store_true", help="跳过交互确认(非交互环境必须显式给)")
-    p.set_defaults(func=cmd_write)
-
-    return parser
-
-
-def main(argv=None):
-    args = build_parser().parse_args(argv)
-    try:
-        return args.func(args)
-    except WpError as exc:
-        print(f"错误:{exc}", file=sys.stderr)
-        return 1
-    except KeyboardInterrupt:
-        print("\n已中断。", file=sys.stderr)
-        return 130
-
-
-if __name__ == "__main__":
-    sys.exit(main())