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

Merge pull request #2433 from visuddhinanda/development

Development
visuddhinanda 1 неделя назад
Родитель
Сommit
8348713070

+ 126 - 0
docs/wikipali-feature-coverage.md

@@ -0,0 +1,126 @@
+# WikiPali 功能覆盖清单
+
+> 人类做佛教研究与翻译时用到的 WikiPali 功能(用户 2026-08-08 给出的清单),
+> 对照 `wikipali` 插件当前的实现状态。
+>
+> 用途:逐项落实的工作清单。**标 ⬜ 的需要提供一个能跑通的完整 API URL**——
+> 照着反推参数比读控制器快,也不会猜错。
+>
+> 插件版本基准:0.5.0(2026-08-08)
+
+**图例**
+
+| 记号 | 含义 |
+|---|---|
+| ✅ | 已实现,有命令 |
+| 🔧 | 端点已确认可用,只差封装成命令 |
+| ⚠️ | 能做,但体验或粒度不到位 |
+| ⬜ | **需要 API URL**(端点可能存在但参数未知,或路由里没找到)|
+
+---
+
+## 一、研究(读取)
+
+### 1. 字典
+
+| 功能 | 状态 | 实现 / 端点 | 备注 |
+|---|---|---|---|
+| 词典释义 | ✅ | `wikipali word` → `GET /v2/dict?word=&lang=` | 释义在 `note` 字段,不是 `description` |
+| 词形展开 | ✅ | `wikipali forms` → `GET /v2/case/{词}` | 检索的必经前置;拿词典形直接搜会 0 条且不报错 |
+
+### 2. 术语
+
+| 功能 | 状态 | 实现 / 端点 | 备注 |
+|---|---|---|---|
+| 术语表 | ✅ | `wikipali terms` → `GET /v2/term-vocabulary?view=&lang=` | 全表 17074 条(zh-Hans),本地缓存后过滤 |
+| 单个术语查询 | ⬜ | `GET /v2/system-term/{lang}/{word}`? | 实测返回 `{"ok":false,"message":"no channel"}` —— **缺参数** |
+
+### 3. 三藏
+
+| 功能 | 状态 | 实现 / 端点 | 备注 |
+|---|---|---|---|
+| 分类目录(如「长部的复注有哪些」)| ⬜ | `tag` / `tags-in-chapter` / `tag-map`? | `GET /v2/tag?view=public` → 500。`GET /v2/book-title?view=public` 可用(200)。**缺参数** |
+| 某本书的目录 | ✅ | `wikipali toc` → `GET /v2/palitext?view=book-toc&book=&para=` | 返回整套丛书,客户端按 book 过滤 |
+| 章节内容 · 查有哪些版本 | ⚠️ | `wikipali versions` → `GET /v2/channel?view=paragraphs&book_id=&para=` | **只能按段落查**;按章节查目前用章节起始段近似 |
+| 章节内容 · 读某一版本 | ✅ | `wikipali chapter <坐标> --fetch --channel <uid>` | 先报体量(`chapter_strlen`)再取 |
+| 段落内容 · 多版本 | ✅ | `wikipali versions` → `wikipali get <坐标> --channel <uid>` | 查存在的版本,一次读一个 |
+| 句子内容 · 多版本 | ✅ | 同上 | 句子是 `get` 的最小返回粒度,带 `word_start`/`word_end` |
+| 相似句 | ⬜ | `GET /v2/sent-sim`? | 实测 500。库里 `sent_sims` 表约 3.6 GB。**缺参数** |
+
+### 4. 文章
+
+| 功能 | 状态 | 实现 / 端点 | 备注 |
+|---|---|---|---|
+| 文章 | 🔧 | `GET /v2/article?view=public&limit=` | 实测 200 可用,未封装 |
+| 文集 | 🔧 | `GET /v2/anthology?view=public&limit=` | 实测 200 可用,未封装(控制器是 `CollectionController`)|
+
+### 5. 相关经文(根本 ↔ 义注 ↔ 复注)
+
+| 功能 | 状态 | 实现 / 端点 | 备注 |
+|---|---|---|---|
+| 相关段落 | 🔧 | `GET /v2/related-paragraph?book=&para=` | 已评估:63 万行 / 217 部书,段落覆盖率 97.9–99.9%,双向可用,带 tags 可标层次。服务端「无关联时 500」已修复。**命令待做(0.6.0)** |
+| 相关章节 | ⬜ | ? | 路由里没找到 |
+| 相关书 | ⬜ | ? | 路由里没找到;`related-paragraph` 的返回里有书级信息,但不确定是否等价 |
+
+### 6. 评论
+
+| 功能 | 状态 | 实现 / 端点 | 备注 |
+|---|---|---|---|
+| 章节评论 | ⬜ | `discussion` / `discussion-count`? | **缺参数** |
+| 段落评论 | ⬜ | 同上 | **缺参数** |
+| 句子评论 | ⬜ | `discussion` / `sent-discussion-tree`(POST)/ `discussion-anchor/{id}`? | `GET /v2/discussion?view=sentence&res_id=x` → 500。**缺参数** |
+
+---
+
+## 二、写入
+
+| 功能 | 状态 | 实现 / 端点 | 备注 |
+|---|---|---|---|
+| 句子 | ✅ | `wikipali write` | 模型身份署名、写前确认、count 核对、401 自动重签一次 |
+| 术语 | ⬜ | `POST /v2/terms`(`DhammaTermController`)? | `GET /v2/terms?view=public&key=` → 500。**缺参数** |
+| 评论 · 句子 | ⬜ | `POST /v2/discussion`? | **缺参数** |
+| 修改建议 | ⬜ | ? | 路由里没找到独立端点;`SentResource` 里有 `suggestionCount`,代码里有 `SuggestionApi` |
+| 文章 | 🔧 | `POST /v2/article` | 端点在,未封装 |
+| 文集 | 🔧 | `POST /v2/anthology` | 端点在,未封装 |
+
+---
+
+## 三、需要提供 URL 的清单(共 8 项)
+
+按对研究流程的价值排序:
+
+1. **相似句** `sent-sim` —— 对读与校勘的核心能力,数据量最大(3.6 GB)
+2. **分类目录** `tag` 系列 —— 「长部的复注有哪些」这类浏览,是研究的起点
+3. **单个术语查询** `system-term/{lang}/{word}` —— 现在只能靠全表缓存过滤
+4. **相关章节** —— 有了相关段落,章节级对应能省大量往返
+5. **相关书** —— 同上
+6. **句子评论(读)** `discussion` —— 前人对某句的讨论是重要的二手材料
+7. **术语写入** `terms` —— 研究产出的术语能回流
+8. **修改建议** —— 写入侧的协作能力
+
+每项给一个**能跑通的完整 URL**即可(含参数与示例值),我照着反推。
+
+---
+
+## 四、已排期
+
+| 版本 | 内容 | 依赖 |
+|---|---|---|
+| 0.6.0 | `related`(相关段落)· `article` / `anthology`(文章与文集读取) | 无,可立即开工 |
+| 待定 | 上面 8 项,收到 URL 后按价值排 | 用户提供 URL |
+| 待定 | 按章节聚合分布(`dist --by chapter`),方案见 `wikipali-research-agent-design.md` §3.7 | 方案待定 |
+| 待定 | 短语检索改走 `/v3/search`(OpenSearch),见 §3.6 | v3 调试完成 |
+| 待定 | `versions` 支持按章节查(现在只能按段落,章节用起始段近似) | 可能需要服务端支持 |
+
+---
+
+## 五、两个记录在案的判断
+
+**多版本不做并排对照。** 一度考虑把巴利原文、缅文 nissaya、汉译按 `(book, paragraph,
+word_start, word_end)` 四元组对齐后并排输出。用户 2026-08-09 否定:正确做法是
+**先查有哪些版本,一次只读一个**。理由是上下文预算——一次拉多个完整版本会直接撑爆,
+而研究时本来就是逐个版本读。所以 `versions` → `get/chapter --channel` 这条链就是最终形态。
+
+**`versions` 的粒度缺口。** 它按段落查,而用户的用法是「给章节编号,查存在的版本」。
+目前只能用章节起始段近似——同一章内不同段落的版本覆盖可能不同(某译本只译了半章)。
+是否需要章节级的查询,取决于实际使用中这个近似会不会出错。

+ 8 - 0
docs/wikipali-research-agent-design.md

@@ -203,6 +203,14 @@ Cūḷavaggapāḷi, Pārivāsikakkhandhaka (VN 216:35)
 Samantapāsādikā, Pārivāsikavattakathā (SP-aṭṭ 141:63)
 ```
 
+**发现(2026-08-09)**:平台自己就有引用格式。用户写的文章《表24:三种别住》里,
+引用巴利原文用的是 `{{141-120-17-40}}` —— 即 `{{book-paragraph-word_start-word_end}}`,
+**精确到句**。实测该坐标正是义注里讲 `odhānasamodhāna` 的那一句。
+
+这比我临时定的格式好:它是平台原生的,写成这样的引用在 wikipali 上能直接解析定位。
+**待用户确认是否采用**——若采用,`conventions.md` 的「引用格式」一节改为这个,
+`research` 规程要求产出中的巴利原文引用一律用它。
+
 **⬜ TODO:用户之后会给出正式的引用格式规范**,届时改 `skills/research/SKILL.md` 的「引用格式」一节。这关系到产出能否被同行接受,属于必改项,不是可选优化。
 
 相关线索:库里有 `page_numbers` 表,`type` 分 `M/P/T/V/O`(缅甸版/PTS 等不同版本的页码),正式规范多半要用到其中某一种;`GET /v2/search?view=page&key=<卷.页>&type=<版本>` 是按页码反查段落的现成端点。

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

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

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

@@ -107,6 +107,30 @@ def build_parser():
     p.add_argument('--refresh', action='store_true', help='强制重新拉取(全表有缓存)')
     p.set_defaults(func=cmd_read.cmd_terms)
 
+    p = add('related', '本文 ↔ 义注 ↔ 复注的段落对应')
+    p.add_argument('coord', help='book:paragraph')
+    p.set_defaults(func=cmd_read.cmd_related)
+
+    p = add('articles', '列出 / 搜索文章(二手研究)')
+    p.add_argument('keyword', nargs='?', help='标题关键词')
+    p.add_argument('--lang', help='按语言过滤')
+    p.add_argument('--view', default='public')
+    p.add_argument('--limit', type=int, default=20)
+    p.add_argument('--offset', type=int, default=0)
+    p.set_defaults(func=cmd_read.cmd_articles)
+
+    p = add('article', '读一篇文章的全文')
+    p.add_argument('uid')
+    p.add_argument('--chars', type=int, default=4000, help='最多输出多少字符,0 为不截断')
+    p.set_defaults(func=cmd_read.cmd_article)
+
+    p = add('anthology', '文集:不给 uid 列表,给 uid 看目录')
+    p.add_argument('uid', nargs='?')
+    p.add_argument('--view', default='public')
+    p.add_argument('--limit', type=int, default=30)
+    p.add_argument('--offset', type=int, default=0)
+    p.set_defaults(func=cmd_read.cmd_anthology)
+
     # -- 写 ----------------------------------------------------------------
     p = add('ensure-model', '幂等地建立模型记录并取模型身份 token', needs_json=False)
     p.add_argument('--name', help='模型标识,如 claude-opus-5(会成为句子作者署名)')

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

@@ -541,3 +541,161 @@ def cmd_terms(args):
 
     emit(args, hits, render)
     return 0
+
+
+# ---------------------------------------------------------------------------
+# related —— 本文 ↔ 义注 ↔ 复注的段落对应
+# ---------------------------------------------------------------------------
+
+
+def cmd_related(args):
+    client = make_client(args)
+    book, para = parse_coord(args.coord)
+    try:
+        data = client.call('GET', 'v2/related-paragraph', query={'book': 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'
+                '最可能的原因是**该段没有关联段落**——稳定版站点在这种情况下会报 500,\n'
+                '服务端修复已合并但尚未部署。也可能是服务本身有问题。\n'
+                '两者无法从这里区分,**不要据此断言「该段有/没有注释」**;\n'
+                '可以换最新版试试:wikipali --api next related {0}:{1}'.format(book, para)
+            )
+        raise explain_api_error(exc, f'查 {book}:{para} 的关联段落')
+    rows = (data or {}).get('rows') or []
+
+    def render():
+        if not rows:
+            print(f'{book}:{para} 没有关联段落。')
+            print('约 2% 的段落没有 CST 锚点,这是正常结果,不是查询失败——'
+                  '如实报告,不要转而去注释书里搜关键词充数。')
+            return
+        print(f'{book}:{para} 关联到 {len(rows)} 部书:\n')
+        order = {'mūla': 0, 'aṭṭhakathā': 1, 'ṭīkā': 2}
+        rows.sort(key=lambda r: order.get(text_layer(r.get('tags')), 9))
+        for r in rows:
+            layer = text_layer(r.get('tags')) or '未标层次'
+            paras = r.get('para') or []
+            coords = ' '.join(f'{r.get("book")}:{p}' for p in paras[:8])
+            more = f' …共 {len(paras)} 段' if len(paras) > 8 else ''
+            here = '  ← 当前' if int(r.get('book', -1)) == book and para in paras else ''
+            print(f'  [{layer:<11}] {str(r.get("book_title_pali"))[:26]:<28}{here}')
+            print(f'      {coords}{more}')
+        first = rows[0]
+        print(f'\n取文:wikipali get {first.get("book")}:{(first.get("para") or [0])[0]}')
+        print('引用时必须标明层次——把义注的解释当成本文的说法是学术错误。')
+
+    emit(args, rows, render)
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# articles / article / anthology —— 文章与文集
+# ---------------------------------------------------------------------------
+
+
+def cmd_articles(args):
+    client = make_client(args)
+    query = {'view': args.view, 'limit': args.limit, 'offset': args.offset}
+    if args.keyword:
+        query['search'] = args.keyword
+    if args.lang:
+        query['lang'] = args.lang
+    try:
+        data = client.call('GET', 'v2/article', query=query, timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, '列出文章')
+    rows = (data or {}).get('rows') or []
+
+    def render():
+        print(f'共 {(data or {}).get("count")} 篇,本页 {len(rows)}'
+              + (f'(关键词「{args.keyword}」)' if args.keyword else ''))
+        if not rows:
+            return
+        print()
+        for r in rows:
+            who = (r.get('editor') or {}).get('nickName') or ''
+            sub = f'  —— {r["subtitle"]}' if r.get('subtitle') else ''
+            print(f'  {str(r.get("lang")):<8} {str(r.get("title"))[:40]:<42}{sub}')
+            print(f'      {r.get("uid")}   {who}   {str(r.get("updated_at"))[:10]}')
+        print(f'\n读全文:wikipali article {rows[0].get("uid")}')
+
+    emit(args, rows, render)
+    return 0
+
+
+def cmd_article(args):
+    client = make_client(args)
+    try:
+        art = client.call('GET', f'v2/article/{args.uid}', timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, f'读文章 {args.uid}')
+    if not art:
+        raise WpError(f'读不到文章 {args.uid}。')
+
+    def render():
+        who = (art.get('editor') or {}).get('nickName') or ''
+        studio = (art.get('studio') or {}).get('nickName') or ''
+        print(f'# {art.get("title")}')
+        if art.get('subtitle'):
+            print(f'  {art["subtitle"]}')
+        print(f'  {art.get("lang")}  作者 {who}  studio {studio}  更新 {str(art.get("updated_at"))[:10]}')
+        print(f'  uid {art.get("uid")}\n')
+        body = art.get('content') or ''
+        if args.chars and len(body) > args.chars:
+            print(body[: args.chars])
+            print(f'\n……全文 {len(body)} 字符,此处截断(--chars 0 取全文)')
+        else:
+            print(body)
+        print('\n⚠ 文章是**二手研究**,不是原典。引用它的观点要标明作者,'
+              '不要把它的说法当成经律本身的说法。')
+
+    emit(args, art, render)
+    return 0
+
+
+def cmd_anthology(args):
+    client = make_client(args)
+    if args.uid:
+        try:
+            data = client.call('GET', f'v2/anthology/{args.uid}', timeout=READ_TIMEOUT)
+        except ApiError as exc:
+            raise explain_api_error(exc, f'读文集 {args.uid}')
+        arts = (data or {}).get('article_list') or []
+
+        def render():
+            print(f'# {data.get("title")}   {data.get("lang")}')
+            if data.get('summary'):
+                print(f'  {data["summary"]}')
+            print(f'  {len(arts)} 篇文章\n')
+            for a in arts[: args.limit]:
+                if isinstance(a, dict):
+                    print(f'  {str(a.get("title"))[:44]:<46} {a.get("uid")}')
+                else:
+                    print(f'  {a}')
+        emit(args, data, render)
+        return 0
+
+    try:
+        data = client.call('GET', 'v2/anthology',
+                           query={'view': args.view, 'limit': args.limit, 'offset': args.offset},
+                           timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        raise explain_api_error(exc, '列出文集')
+    rows = (data or {}).get('rows') or []
+
+    def render():
+        print(f'共 {(data or {}).get("count")} 个文集,本页 {len(rows)}\n')
+        for r in rows:
+            print(f'  {str(r.get("lang")):<8} {str(r.get("title"))[:40]:<42} '
+                  f'{r.get("childrenNumber")} 篇')
+            print(f'      {r.get("uid")}')
+        if rows:
+            print(f'\n看目录:wikipali anthology {rows[0].get("uid")}')
+
+    emit(args, rows, render)
+    return 0

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

@@ -28,6 +28,17 @@ skill 共用的规矩,必须遵守。** 端点细节见 `references/api-read.m
 
 ## 流程
 
+### 0. 先看有没有人写过
+
+```bash
+wikipali articles 别住          # 平台上的二手研究
+wikipali anthology              # 文集(成体系的系列文章)
+wikipali article <uid>          # 读全文
+```
+
+**几秒钟的事,能省掉重复劳动,也能发现你要处理的分歧点。** 但记住文章是二手研究:
+引用它的观点要标明作者,**不能把它的说法当成经律本身的说法**。
+
 ### 1. 展开词形(永远的第一步)
 
 ```bash
@@ -143,6 +154,21 @@ wikipali chapter 216:512 --fetch        # 确认要读全章时才加 --fetch
 `chapter` 给正文段也行,会自动向上找到所属章节。**不加 `--fetch` 就只报体量**——
 这是上下文预算的闸门,先看清多大再决定读不读。
 
+### 5b. 从本文跳到义注与复注
+
+```bash
+wikipali related 216:512        # 该段在义注、复注里的对应段落
+wikipali get 141:65 141:66      # 读义注怎么解释这一段
+```
+
+**这是找注释的正确方式,不要回头去注释书里搜关键词。** 关键词搜到的未必是在解释这一段,
+而注释书解释某段时也未必重复原词——两头都会错。`related` 走的是 CST 锚点的段落对应关系,
+是文献学上正确的对齐。
+
+输出按 mūla → aṭṭhakathā → ṭīkā 排序并标好层次,直接可用于引用。
+
+约 2% 的段落没有锚点,那时会明确报「没有关联段落」——**如实说,不要转而搜关键词充数**。
+
 ### 6. 交叉验证
 
 ```bash