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

feat(plugin): 0.6.0 —— related(本文↔义注↔复注)与文章/文集

三个命令 + 规程两处补充:

- related:走 CST 锚点的段落对应,按 mūla → aṭṭhakathā → ṭīkā 排序并标
  层次。这是找注释的正确方式——回头去注释书里搜关键词两头都会错:搜到
  的未必在解释这一段,注释书解释某段时也未必重复原词;
- articles / article / anthology:平台上的二手研究。规程里放在第 0 步,
  开题前几秒钟就能发现别人写过什么、分歧在哪;
- 输出都带「文章是二手研究,不能当成经律本身的说法」的提醒。

related 对服务端 500 的处置:查无关联时稳定版会 500(修复已合并未部署),
客户端不能替服务端断言,所以明确告诉使用者「最可能是该段没有关联段落,
但也可能是服务故障,两者无法从这里区分,不要据此断言有/没有注释」,并
给出换最新版重试的办法。部署后这条分支自然走到「没有关联段落」。

实测交叉印证:搜「别住」找到用户自己写的《表24:三种别住》,其中的四种
别住与我们从 Pācityādiyojanā 独立检索到的一致,而它引用巴利原文用的
{{141-120-17-40}} 格式,指向的正是我们用 get 141:120 读到的那一句。

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

+ 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