Procházet zdrojové kódy

feat(plugin): 0.5.0 —— R2 五个新命令,源自 pali-translab 的需求盘点

读了 visuddhinanda/pali-translab 的 DATA_FETCH.md,它正要写的客户端与本
插件重叠,但它摸清了四个我们没封装的端点。逐个实测后补进来:

  toc       章节目录(palitext?view=book-toc)
  chapter   先报体量再取整章(palitext/{book}-{para} 的 chapter_len /
            chapter_strlen)——SKILL.md 里「命令会先报字数」这句原本没有
            数据来源,现在有了
  versions  某坐标有哪些译本、没有哪些(channel?view=paragraphs)——这个
            端点原先是 versions 无法实现的障碍,现在解决了
  count     词频合计。3b 要求逐个查子类,原先只能用 forms --json 求和
  terms     术语表(term-vocabulary),权威译名对照

实测中确认并写进 references/api-read.md 的坑:

- palitext/{book}-{para} 的 path 是 **JSON 字符串**,而 search 返回的是
  数组,客户端两种都要能吃;
- 正文段自己也带 chapter_len 且值为 1,判断「是不是章节」要看 > 1 而不是
  看字段存不存在(第一版就栽在这,chapter 216:512 报成「1 段」);
- 从正文段找所属章节要取 path 末项,不是 parent;
- pcd_book_id 等于 search-pali-wbw 的 book 参数值(216 → 278),这回答了
  pali-translab 的一个 TODO;
- channel?view=paragraphs 在稳定版站点 500、最新版正常(修复提交
  c392d33c3 还没部署到 www)——**设计文档预警的版本差第一次真实出现**。
  versions 因此在 5xx 时提示切 next,而不是当成「该段没有译本」;
- term-vocabulary 不支持按词查询,只能拉全表(zh-Hans 17074 条),故加了
  本地缓存 + --refresh。

顺带修正机器译文判定:versions 立刻暴露 216:512 有个叫 deepseek 的
zh-Hans channel——名字里没有 "AI",原来的判据漏了它。库里还有 qwen-max /
grok-简体中文 / gemini / 豆包 / ChatGPT 这类以模型名命名的 channel。改为
按模型名清单提示「⚠疑似机器译」,并写明**没标的不等于人译**,权威判定看
get 返回的作者是不是模型。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda před 1 týdnem
rodič
revize
7c8468a043

+ 1 - 1
plugins/wikipali/.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "wikipali",
   "description": "WikiPali 巴利三藏平台的客户端:检索与阅读语料做研究(词形展开、全文检索、出处分布、按坐标取原文与译本),以及以 AI 模型身份写入句子。",
-  "version": "0.4.0",
+  "version": "0.5.0",
   "author": {
     "name": "visuddhinanda",
     "url": "https://github.com/visuddhinanda"

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

@@ -77,6 +77,36 @@ def build_parser():
     p.add_argument('--limit', type=int, default=200, help='每次请求最多取几句')
     p.set_defaults(func=cmd_read.cmd_get)
 
+    p = add('toc', '章节目录')
+    p.add_argument('coord', help='book:paragraph,任意段号即可,服务端会找到所属丛书')
+    p.add_argument('--depth', type=int, default=4, help='最多显示到第几层,默认 4')
+    p.add_argument('--all', action='store_true', help='显示整套丛书而不只当前这本')
+    p.set_defaults(func=cmd_read.cmd_toc)
+
+    p = add('chapter', '先报体量,再按需取整章')
+    p.add_argument('coord', help='book:paragraph,正文段也行,会自动向上找章节')
+    p.add_argument('--fetch', action='store_true', help='确认要读全文时加这个;不加只报体量')
+    p.add_argument('--channel', action='append', help='channel uid,可重复;缺省取巴利原文')
+    p.add_argument('--warn-at', type=int, default=8000, help='超过多少字符就提示,默认 8000')
+    p.add_argument('--limit', type=int, default=200)
+    p.set_defaults(func=cmd_read.cmd_chapter)
+
+    p = add('versions', '某坐标有哪些译本,以及没有哪些')
+    p.add_argument('coord', help='book:paragraph')
+    p.set_defaults(func=cmd_read.cmd_versions)
+
+    p = add('count', '词频合计(词次,不是段落数)')
+    p.add_argument('words', nargs='+', help='一个或多个词/词根')
+    p.set_defaults(func=cmd_read.cmd_count)
+
+    p = add('terms', '术语表:权威译名对照')
+    p.add_argument('keyword', nargs='?', help='按巴利词形过滤;省略则列全表')
+    p.add_argument('--lang', default='zh-Hans')
+    p.add_argument('--view', default='community')
+    p.add_argument('--limit', type=int, default=30)
+    p.add_argument('--refresh', action='store_true', help='强制重新拉取(全表有缓存)')
+    p.set_defaults(func=cmd_read.cmd_terms)
+
     # -- 写 ----------------------------------------------------------------
     p = add('ensure-model', '幂等地建立模型记录并取模型身份 token', needs_json=False)
     p.add_argument('--name', help='模型标识,如 claude-opus-5(会成为句子作者署名)')

+ 260 - 1
plugins/wikipali/lib/cmd_read.py

@@ -9,13 +9,19 @@ import re
 import sys
 
 from client import make_client, note
-from coords import fmt_coord, fmt_path, parse_coords, text_layer
+from coords import fmt_coord, fmt_path, parse_coord, parse_coords, text_layer
 from errors import ApiError, WpError, explain_api_error
 
 # 巴利原文本身就是一个 channel(_System_Pali_VRI_)。取原文、取译文、取逐词解析
 # 是同一个调用换 channel。
 PALI_CHANNEL = '00b577c0-13b9-11ee-a05a-b7307efd9ee6'
 
+# 靠 channel 名字判断机器译文很脆弱:库里既有名字含 "AI" 的,也有直接用模型名命名的
+# (deepseek / qwen-max / grok-简体中文 / gemini / 豆包 / ChatGPT),后者不含 "ai"。
+# 这个清单只用来「提醒去核实」,不作为判定依据——权威判定看 get 返回的作者是不是模型。
+MACHINE_HINTS = ('ai', 'gpt', 'chatgpt', 'claude', 'deepseek', 'gemini', 'qwen', 'grok',
+                 'llama', 'mistral', 'kimi', 'norbu', '豆包', '文心', 'ernie', '通义')
+
 # 服务端的 sentence?view=paragraph 不带 channels 会 500,所以永远要给一个默认值。
 READ_TIMEOUT = 60
 
@@ -282,3 +288,256 @@ def cmd_get(args):
 
     emit(args, collected, render)
     return 0
+
+
+# ---------------------------------------------------------------------------
+# toc —— 章节目录
+# ---------------------------------------------------------------------------
+
+
+def cmd_toc(args):
+    client = make_client(args)
+    book, para = parse_coord(args.coord)
+    try:
+        data = client.call('GET', 'v2/palitext', query={'view': 'book-toc', 'book': book, 'para': para},
+                           timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, f'取 {book}:{para} 的章节目录')
+    rows = (data or {}).get('rows') or []
+    # 服务端返回的是整套丛书的目录,默认只留当前这本,避免刷屏
+    shown = rows if args.all else [r for r in rows if r.get('book') == book]
+
+    def render():
+        print(f'{len(rows)} 条目录条目'
+              + ('' if args.all else f',其中 book {book} 有 {len(shown)} 条(--all 看整套丛书)'))
+        for r in shown:
+            level = int(r.get('level') or 1)
+            if level > args.depth:
+                continue
+            print(f'{"  " * (level - 1)}{r.get("book")}:{r.get("paragraph")}  {r.get("toc")}')
+
+    emit(args, shown, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# chapter —— 先报体量,再取整章
+# ---------------------------------------------------------------------------
+
+
+def fetch_meta(client, book, para):
+    try:
+        return client.call('GET', f'v2/palitext/{book}-{para}', timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, f'取 {book}:{para} 的段落元信息')
+
+
+def parse_path(raw):
+    """这个端点的 path 是 JSON 字符串,search 那边却是数组——两边都要能吃。"""
+    if isinstance(raw, str):
+        try:
+            return json.loads(raw)
+        except ValueError:
+            return []
+    return raw or []
+
+
+def resolve_chapter(client, book, para):
+    """给任意段号,向上找到它所属的章节节点。返回 (章节 meta, 走了几层)。
+
+    注意:正文段自己也带 chapter_len(值为 1),所以不能用「有没有这个字段」判断,
+    要看它是不是 > 1。向上一层优先取 path 的末项(那就是直接所属的章节),
+    没有 path 才退回 parent。
+    """
+    meta = fetch_meta(client, book, para)
+    hops = 0
+    while meta and int(meta.get('chapter_len') or 0) <= 1 and hops < 6:
+        up = None
+        path = parse_path(meta.get('path'))
+        if path:
+            last = path[-1]
+            if int(last.get('paragraph', -1)) != int(meta.get('paragraph', -1)):
+                up = int(last['paragraph'])
+        if up is None and meta.get('parent'):
+            up = int(meta['parent'])
+        if up is None:
+            break
+        meta = fetch_meta(client, book, up)
+        hops += 1
+    return meta, hops
+
+
+def cmd_chapter(args):
+    client = make_client(args)
+    book, para = parse_coord(args.coord)
+    meta, hops = resolve_chapter(client, book, para)
+    if not meta or not meta.get('chapter_len'):
+        raise WpError(f'{book}:{para} 向上找不到章节节点,无法确定章节范围。')
+
+    start = int(meta['paragraph'])
+    length = int(meta['chapter_len'])
+    strlen = int(meta.get('chapter_strlen') or 0)
+    end = start + length - 1
+    path = parse_path(meta.get('path'))
+    title = meta.get('toc') or meta.get('title') or (path[-1].get('title') if path else '')
+
+    print(f'章节   : {title}')
+    print(f'路径   : {fmt_path(path)}')
+    print(f'范围   : {book}:{start} – {book}:{end}({length} 段)'
+          + (f',约 {strlen} 字符' if strlen else ''))
+    if hops:
+        print(f'({book}:{para} 是正文段,向上 {hops} 层找到所属章节)')
+    if meta.get('prev_chapter') or meta.get('next_chapter'):
+        print(f'相邻   : 上一章 {book}:{meta.get("prev_chapter")}  下一章 {book}:{meta.get("next_chapter")}')
+
+    if not args.fetch:
+        print(f'\n只报体量,未取文。确认要读再加 --fetch;只要其中几段用:'
+              f'wikipali get {book}:{start} {book}:{start + 1} …')
+        return 0
+
+    if strlen > args.warn_at:
+        note(f'⚠ 本章约 {strlen} 字符,超过 {args.warn_at} 的提示阈值——注意上下文预算。')
+
+    args.coords = [f'{book}:{p}' for p in range(start, end + 1)]
+    args.limit = max(args.limit, length * 20)
+    return cmd_get(args)
+
+
+# ---------------------------------------------------------------------------
+# versions —— 某坐标有哪些译本,以及没有哪些
+# ---------------------------------------------------------------------------
+
+
+def cmd_versions(args):
+    client = make_client(args)
+    book, para = parse_coord(args.coord)
+    try:
+        data = client.call('GET', 'v2/channel',
+                           query={'view': 'paragraphs', 'book_id': book, 'para': para},
+                           timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        if exc.status and exc.status >= 500:
+            raise WpError(
+                f'查 {book}:{para} 的可用译本失败(HTTP {exc.status})。\n'
+                '稳定版站点上 channel?view=paragraphs 有已知缺陷,修复只在最新版代码上。\n'
+                '请切到最新版再试:wikipali endpoint next,或本次调用加 --api next。'
+            )
+        raise explain_api_error(exc, f'查 {book}:{para} 的可用译本')
+    rows = (data or {}).get('rows') or []
+
+    def render():
+        if not rows:
+            print(f'{book}:{para} 在任何 channel 下都没有内容。')
+            return
+        print(f'{book}:{para} 有 {len(rows)} 个 channel 存有内容:\n')
+        by_type = {}
+        for r in rows:
+            by_type.setdefault(r.get('type') or '?', []).append(r)
+        for typ in sorted(by_type):
+            print(f'  [{typ}]')
+            for r in sorted(by_type[typ], key=lambda x: str(x.get('lang'))):
+                name_l = (r.get('name') or '').lower()
+                ai = ' ⚠疑似机器译' if any(h in name_l for h in MACHINE_HINTS) else ''
+                print(f'    {str(r.get("lang")):<8} {str(r.get("name"))[:36]:<38} {r.get("uid")}{ai}')
+        langs = {str(r.get('lang')) for r in rows}
+        missing = [l for l in ('pali', 'my', 'zh-Hans', 'zh', 'en', 'th') if l not in langs]
+        if missing:
+            print(f'\n该段**没有**这些语言的内容:{", ".join(missing)}')
+            print('如实报告「无」,不要拿相邻段落或别的译本凑。')
+        print('\n标 ⚠疑似机器译 的按机器译文标注引用。**没标的不等于是人译**——'
+              '名字判断很脆弱,权威做法是 wikipali get 看作者是不是模型,见 conventions.md。')
+
+    emit(args, rows, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# count —— 词频合计
+# ---------------------------------------------------------------------------
+
+
+def cmd_count(args):
+    client = make_client(args)
+    out = []
+    for word in args.words:
+        rows = fetch_forms(client, word)
+        if not rows:
+            out.append({'word': word, 'found': False})
+            continue
+        top = rows[0]
+        forms = top.get('case') or []
+        out.append({
+            'word': word, 'found': True, 'lemma': top.get('word'),
+            'forms': len(forms),
+            'total': sum(int(f.get('count') or 0) for f in forms),
+            'bold': sum(int(f.get('bold') or 0) for f in forms),
+        })
+
+    def render():
+        print(f'{"词":<28}{"词根":<24}{"词形":>5}{"词次":>8}{"黑体":>7}')
+        for r in out:
+            if not r['found']:
+                print(f'{r["word"]:<28}{"(语料中未见)":<24}')
+                continue
+            print(f'{r["word"]:<28}{r["lemma"]:<24}{r["forms"]:>5}{r["total"]:>8}{r["bold"]:>7}')
+        print('\n这里数的是**词次**,不是段落数。段落数用 search 的 count。')
+
+    emit(args, out, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# terms —— 术语表(权威译名对照)
+# ---------------------------------------------------------------------------
+
+
+def terms_cache_path(lang, view):
+    import os
+    from creds import CREDS_DIR
+    return os.path.join(CREDS_DIR, 'cache', f'terms-{view}-{lang}.json')
+
+
+def cmd_terms(args):
+    import os
+    client = make_client(args)
+    path = terms_cache_path(args.lang, args.view)
+    rows = None
+    if os.path.exists(path) and not args.refresh:
+        try:
+            with open(path, encoding='utf-8') as fh:
+                rows = json.load(fh)
+        except (OSError, ValueError):
+            rows = None
+    if rows is None:
+        note('正在拉取术语表全表(服务端不支持按词查询,只能整表拉后本地过滤)…')
+        try:
+            data = client.call('GET', 'v2/term-vocabulary',
+                               query={'view': args.view, 'lang': args.lang}, timeout=120)
+        except ApiError as exc:
+            raise explain_api_error(exc, '取术语表')
+        rows = (data or {}).get('rows') or []
+        os.makedirs(os.path.dirname(path), exist_ok=True)
+        with open(path, 'w', encoding='utf-8') as fh:
+            json.dump(rows, fh, ensure_ascii=False)
+        note(f'已缓存 {len(rows)} 条到 {path}(--refresh 可强制更新)')
+
+    kw = (args.keyword or '').lower()
+    hits = [r for r in rows if kw in (r.get('word') or '').lower()] if kw else rows
+
+    def render():
+        if not hits:
+            print(f'术语表({args.view} / {args.lang},共 {len(rows)} 条)里没有含「{args.keyword}」的词条。')
+            print('注意:这只说明术语表没收录,不代表语料里没有这个词。')
+            return
+        print(f'{len(hits)} 条(全表 {len(rows)}):\n')
+        for r in hits[: args.limit]:
+            tag = f'  [{r["tag"]}]' if r.get('tag') else ''
+            other = f'  / {r["other_meaning"]}' if r.get('other_meaning') else ''
+            print(f'  {r.get("word"):<32} {r.get("meaning")}{other}{tag}')
+        if len(hits) > args.limit:
+            print(f'  …… 其余 {len(hits) - args.limit} 条(--limit 调整)')
+        print('\n术语表是**权威译名对照**,写译文或论文时的用词应与它一致;'
+              '与它不一致时要说明理由。')
+
+    emit(args, hits, render)
+    return 0

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

@@ -87,6 +87,49 @@ word_start, word_end, editor, channel, updated_at}`。按 `word_start` 排序拼
 `view=book-toc` / `chapter` / `chapter_children` / `children` / `paragraph`,
 参数 `book` / `para` / `series`。
 
+## 7. 章节目录 —— `GET /v2/palitext?view=book-toc&book={book}&para={任意段号}`
+
+`para` 给该书内任意段号即可,服务端自己往上找顶级目录。返回的是**整套丛书**的目录
+(如给 book 216 会连 213/214/… 一起返回,实测 951 条),要按 `book` 自行过滤。
+
+行字段:`book` / `paragraph` / `toc`(巴利标题)/ `level`(1–7,1 为顶层)。
+
+## 8. 段落与章节元信息 —— `GET /v2/palitext/{book}-{paragraph}`
+
+⚠ 是 **path 参数**(`/palitext/216-481`),不是 query。`data` 是**单个对象**,不是 rows。
+
+| 字段 | 说明 |
+|---|---|
+| `class` | `chapter` / `subhead` / `bodytext` … |
+| `chapter_len` | **章节段数**。⚠ 正文段自己也有这个字段且值为 1,所以判断「是不是章节」要看它 **> 1**,不能看有没有 |
+| `chapter_strlen` | 章节字符数——**取整章前报体量就靠它** |
+| `next_chapter` / `prev_chapter` / `parent` | 导航 |
+| `path` | 面包屑。⚠ **这个端点返回的是 JSON 字符串,而 search 返回的是数组**,客户端两种都要能吃 |
+| `pcd_book_id` | 另一套书号,**等于 `search-pali-wbw` 的 `book` 参数值**(实测 216 → 278,与 dist 输出的 `--book 278` 一致)|
+
+从正文段找所属章节:取 `path` 的**末项**,那就是直接父章节;没有 path 才退回 `parent`。
+
+## 9. 某段有哪些译本 —— `GET /v2/channel?view=paragraphs&book_id={book}&para={para}`
+
+返回该坐标下有内容的全部 channel(`uid` / `name` / `type` / `lang` / `status`)。
+
+⚠ **稳定版站点上这个分支会 500**,修复只在最新版代码里(`c392d33c3 :bug: view=paragraphs
+只显示一行`)。客户端遇到 5xx 要提示切到 `next`,不要当成「该段没有译本」。
+
+## 10. 术语表 —— `GET /v2/term-vocabulary?view=community&lang=zh-Hans`
+
+权威译名对照。行字段:`guid` / `word`(巴利词形)/ `tag` / `meaning` / `other_meaning`。
+
+⚠ **不支持按词查询**(带 `word=` 参数返回的不是 JSON),只能拉全表——实测 zh-Hans
+共 **17074 条**,客户端要缓存后本地过滤。
+
+## 其他 channel 视图
+
+`view` 取值:`public`(status=30)/ `studio` / `studio-all` / `user-edit` / `user-in-chapter`
+/ `system` / `paragraphs` / `id`。
+
+`status` 不止 10/30:实测 5(610 个,basic 用户新建的)、30(559,公开)、10(495)、0、1 都在用。
+
 ## 已知故障
 
 | 端点 | 现象 |

+ 9 - 4
plugins/wikipali/references/conventions.md

@@ -45,10 +45,15 @@ AI-汉译-Nissaya(**AI 生成**,deepseek-v3)(216:35)         ← 机器译
 
 1. **作者是 AI 模型**——`get` 返回里的作者若是模型而非人类用户,该译文确定是机器
    生成的,标注时连模型名一起写;
-2. **channel 名字含 "AI" 等字样**——库里存在人工用自己账号上传的机器译文(如
-   `Nissaya的AI翻译`、`Norbu AI Translations`),此时信号 1 不成立。
-
-两个都不命中时**不要主动断言"这是人译"**——只如实标出 channel 名与作者。
+2. **channel 名字像机器译**——库里存在人工用自己账号上传的机器译文(如
+   `Nissaya的AI翻译`、`Norbu AI Translations`),此时信号 1 不成立。但**光看 "AI" 两个字
+   会漏**:很多 channel 直接用模型名命名(`deepseek`、`qwen-max`、`grok-简体中文`、
+   `gemini`、`豆包`、`ChatGPT`),名字里根本没有 "AI"。`versions` 会按一份模型名清单
+   标出「⚠疑似机器译」,但那只是提醒,不是判定。
+
+两个都不命中时**不要主动断言"这是人译"**——只如实标出 channel 名与作者。信号 2 的清单
+永远追不上新出的模型名,所以**只有信号 1(作者是模型)是可靠的**,拿不准就用
+`wikipali get` 看作者。
 
 ## 空结果要诚实
 

+ 25 - 6
plugins/wikipali/skills/research/SKILL.md

@@ -42,8 +42,12 @@ wikipali forms parivāsa
 
 ```bash
 wikipali word parivāsa          # 释义 + 形态分析,确认词根选对了
+wikipali terms parivāsa         # 术语表:该词的权威中文译名
 ```
 
+**产出里的译名应与术语表一致**,不一致要说明理由。别自己从构词法编译名——实测
+`samodhānaparivāsa` 的权威译名是「合并别住」,凭字面容易写成别的。
+
 ### 2. 看分布,再决定范围
 
 ```bash
@@ -90,7 +94,8 @@ wikipali search --lemma parivāsa --limit 50
 每个子类走一遍完整链路:
 
 ```bash
-wikipali forms samodhānaparivāsa                        # 词形与频次
+wikipali count samodhānaparivāsa paṭicchannaparivāsa …  # 一次看清各子类的词次
+wikipali terms samodhānaparivāsa                        # 权威译名
 wikipali search --lemma samodhānaparivāsa --limit 20    # 找它自己的解释段落
 wikipali get <坐标>                                      # 取原文
 ```
@@ -129,16 +134,30 @@ wikipali search --lemma parivāsa --tags vinaya --limit 200
 ### 5. 取原文
 
 ```bash
-wikipali get 216:35 216:36 216:41
+wikipali get 216:35 216:36 216:41       # 按坐标精确取,缺省是巴利原文
+wikipali toc 216:512                    # 看这本书的章节结构
+wikipali chapter 216:512                # 只报体量:章节范围、段数、字符数
+wikipali chapter 216:512 --fetch        # 确认要读全章时才加 --fetch
 ```
 
-缺省取巴利原文。`--channel <uid>` 可指定别的译本(可重复给多个)。
+`chapter` 给正文段也行,会自动向上找到所属章节。**不加 `--fetch` 就只报体量**——
+这是上下文预算的闸门,先看清多大再决定读不读。
 
 ### 6. 交叉验证
 
-用 `--channel` 取缅文逐词解析(nissaya)或各家译本,核对你基于巴利原文做出的判断。
-某坐标在某 channel 下没有内容时,如实说"该译本在此处无文本",**不要拿相邻段落或
-别的译本凑**。
+```bash
+wikipali versions 216:512               # 该坐标有哪些译本,以及没有哪些
+wikipali get 216:512 --channel <uid>    # 取指定译本
+```
+
+`versions` 会把该段**没有**的语言明确列出来——某语言无译文时如实说"无",**不要拿
+相邻段落或别的译本凑**。
+
+⚠ `versions` 依赖的端点在稳定版站点上有缺陷,会提示你切到 `next`(`wikipali endpoint next`)。
+
+⚠ 输出里标 **⚠疑似机器译** 的按机器译文标注引用;但**没标的不等于人译**——很多 channel
+直接用模型名命名(`deepseek`、`qwen-max`、`grok-简体中文`),判定要看 `get` 返回的作者,
+见 `references/conventions.md`。
 
 ## 上下文预算