Преглед изворни кода

feat: tipitaka-content 端点 + 插件 0.8.0 改用它取整章

服务端(用户所写,本次一并提交):
- 新增 TipitakaContentController 与 tipitaka-content 路由。走 OpenSearch 的
  预建文档 tipitaka_chapter_{book}-{para}_{channelId},返回渲染好的 HTML;
  channel 参数是单数,缺省 _System_Pali_VRI_;
- PaliTextService 新增 chapterRange(),由章节起始段与 chapter_len 算出段落区间;
- ChapterContentController 一处形参尾逗号。

插件 0.8.0:
- chapter --fetch 改走 tipitaka-content。sid 就是引用坐标,段落号从 sid 取,
  不必解析外层 data-para;
- **旧的 chapter-content 路径保留**(--via chapter-content),写入侧需要它逐句的
  多版本结构;
- 三种响应分开处理:正常 / 有文档但 0 句 / 400 且 message 带 found:false。第三种
  是「该 channel 在本章无文本」而非服务故障,实测 93-5 上巴利原文、庄春江、
  北大-法胜、Punnacari 有预建文档,wbw 与 deepseek 没有。

两处按用户指正修改:
- <code>M1.1</code> 等是版本页码(M=缅甸版 V=VRI P=PTS T=泰版),标的是页在正文中
  的起始位置、与段落不是一一对应,所以**保留在原位,不抽到单独字段**。此前我提议
  拆成 pages 字段是错的。纯文本模式下转成 [M1.1],否则去标签会让它粘到前一个词上
  (Evaṃ M1.1 → EvaṃM1.1)看着像词形的一部分;
- 与之相关的教训已记进 api-read.md:遇到看不懂的数据格式先问,不要当成冗余删掉。

顺带修一个真 bug:client 的异常捕获漏了 http.client.HTTPException。
IncompleteRead 属于它而非 OSError,两百多万字符的术语表传输中断时会抛裸
traceback 而不是走站点 fallback。实测复现并修复。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda пре 1 недеља
родитељ
комит
a6a2980c6c

+ 1 - 1
api-v13/app/Http/Controllers/ChapterContentController.php

@@ -242,7 +242,7 @@ class ChapterContentController extends Controller
         if (count($record) === 0) {
         if (count($record) === 0) {
             return $this->error("no data");
             return $this->error("no data");
         }
         }
-        $this->result['content'] = json_encode($paliService->makeContentObj($record, $mode, $indexChannel), JSON_UNESCAPED_UNICODE);
+        $this->result['content'] = json_encode($paliService->makeContentObj($record, $mode, $indexChannel,), JSON_UNESCAPED_UNICODE);
         $this->result['content_type'] = 'json';
         $this->result['content_type'] = 'json';
         if (!$request->has('from')) {
         if (!$request->has('from')) {
             //第一次才显示toc
             //第一次才显示toc

+ 65 - 0
api-v13/app/Http/Controllers/TipitakaContentController.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\DTO\Search\HitItemDTO;
+use App\Services\OpenSearchService;
+use App\Http\Api\ChannelApi;
+
+
+class TipitakaContentController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     */
+    public function show(Request $request,string $id)
+    {
+        //
+        $channelId = $request->input('channel',ChannelApi::getSysChannel('_System_Pali_VRI_'));
+        $openSearchId = "tipitaka_chapter_{$id}_{$channelId}";
+
+        try {
+            $doc = HitItemDTO::fromArray(app(OpenSearchService::class)->get($openSearchId))->toArray();
+        } catch (\Throwable $th) {
+            
+            return $this->error('resouce invalid'.$th->getMessage());
+        }
+
+        $display = $doc['display'] ?? '';
+
+        return $this->ok($display);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     */
+    public function update(Request $request, string $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     */
+    public function destroy(string $id)
+    {
+        //
+    }
+}

+ 8 - 1
api-v13/app/Services/PaliTextService.php

@@ -16,7 +16,14 @@ class PaliTextService
         return $parent ? PaliText::where('book', $book)
         return $parent ? PaliText::where('book', $book)
             ->where('paragraph', $parent)->first() : null;
             ->where('paragraph', $parent)->first() : null;
     }
     }
-
+    public function chapterRange(int $book, int $para){
+        $chapter = $this->getCurrChapter($book,$para);
+        if($chapter){
+            return [$book,$chapter->paragraph,$chapter->paragraph+$chapter->chapter_len-1];
+        }else{
+            return null;
+        }
+    }
     public function getCurrChapter(int $book, int $para)
     public function getCurrChapter(int $book, int $para)
     {
     {
         $paragraph = PaliText::where('book', $book)
         $paragraph = PaliText::where('book', $book)

+ 6 - 2
api-v13/routes/api.php

@@ -1,5 +1,8 @@
 <?php
 <?php
 
 
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Route;
+
 use App\Http\Controllers\AccessTokenController;
 use App\Http\Controllers\AccessTokenController;
 use App\Http\Controllers\AiAssistantController;
 use App\Http\Controllers\AiAssistantController;
 use App\Http\Controllers\AiModelController;
 use App\Http\Controllers\AiModelController;
@@ -126,8 +129,8 @@ use App\Http\Controllers\WbwSentenceController;
 use App\Http\Controllers\WbwTemplateController;
 use App\Http\Controllers\WbwTemplateController;
 use App\Http\Controllers\WebHookController;
 use App\Http\Controllers\WebHookController;
 use App\Http\Controllers\WordIndexController;
 use App\Http\Controllers\WordIndexController;
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Route;
+use App\Http\Controllers\TipitakaContentController;
+
 
 
 /*
 /*
 |--------------------------------------------------------------------------
 |--------------------------------------------------------------------------
@@ -313,6 +316,7 @@ Route::group([
     Route::apiResource('chapter-content', ChapterContentController::class);
     Route::apiResource('chapter-content', ChapterContentController::class);
     Route::apiResource('paragraph-content', ParagraphContentController::class);
     Route::apiResource('paragraph-content', ParagraphContentController::class);
     Route::apiResource('heartbeat', HeartbeatController::class);
     Route::apiResource('heartbeat', HeartbeatController::class);
+    Route::apiResource('tipitaka-content', TipitakaContentController::class);
 
 
     Route::post('mock/openai/chat/completions', [MockOpenAIController::class, 'chatCompletions']);
     Route::post('mock/openai/chat/completions', [MockOpenAIController::class, 'chatCompletions']);
     Route::post('mock/openai/completions', [MockOpenAIController::class, 'completions']);
     Route::post('mock/openai/completions', [MockOpenAIController::class, 'completions']);

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

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

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

@@ -89,6 +89,10 @@ def build_parser():
     p.add_argument('--channel', action='append', help='channel uid,可重复;缺省取巴利原文')
     p.add_argument('--channel', action='append', help='channel uid,可重复;缺省取巴利原文')
     p.add_argument('--warn-at', type=int, default=8000, help='超过多少字符就提示,默认 8000')
     p.add_argument('--warn-at', type=int, default=8000, help='超过多少字符就提示,默认 8000')
     p.add_argument('--text', action='store_true', help='输出纯文本而非 html(黑体转成 **)')
     p.add_argument('--text', action='store_true', help='输出纯文本而非 html(黑体转成 **)')
+    p.add_argument('--via', choices=['tipitaka-content', 'chapter-content'],
+                   default='tipitaka-content',
+                   help='取文走哪个端点。默认 tipitaka-content;chapter-content 返回逐句的'
+                        '多版本结构,写入侧需要它')
     p.add_argument('--limit', type=int, default=200)
     p.add_argument('--limit', type=int, default=200)
     p.set_defaults(func=cmd_read.cmd_chapter)
     p.set_defaults(func=cmd_read.cmd_chapter)
 
 

+ 5 - 1
plugins/wikipali/lib/client.py

@@ -1,6 +1,7 @@
 """HTTP 客户端:JSON 请求、线上站点之间出声的 fallback、token 显示辅助。"""
 """HTTP 客户端:JSON 请求、线上站点之间出声的 fallback、token 显示辅助。"""
 
 
 import base64
 import base64
+import http.client
 import json
 import json
 import os
 import os
 import sys
 import sys
@@ -155,7 +156,10 @@ class Client:
         for idx, url in enumerate(urls):
         for idx, url in enumerate(urls):
             try:
             try:
                 data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
                 data = http_json(url, method, path, token=token, body=body, query=query, timeout=timeout)
-            except (urllib.error.URLError, TimeoutError, OSError) as exc:
+            except (urllib.error.URLError, TimeoutError, OSError,
+                    http.client.HTTPException) as exc:
+                # IncompleteRead 属于 HTTPException 而非 OSError——大响应(如两百多万
+                # 字符的术语表)传输中断时会走到这里。不捕获的话会抛裸 traceback。
                 # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
                 # 仅网络层不可达才换站点;HTTP 错误是服务端的明确答复,不该被掩盖
                 last = exc
                 last = exc
                 reason = getattr(exc, "reason", exc)
                 reason = getattr(exc, "reason", exc)

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

@@ -37,6 +37,10 @@ def strip_markup(raw, hl='【】', bold='**'):
     text = raw
     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='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'<span class="bld">(.*?)</span>', bold + r'\1' + bold, text, flags=re.S)
+    # <code>M1.1</code> 是版本页码(M=缅甸版 V=VRI P=PTS T=泰版),标的是页在正文里
+    # 的起始位置,与段落不是一一对应,所以必须留在原位。直接去标签会让它粘到前一个
+    # 词上(Evaṃ M1.1 → EvaṃM1.1),看着像词形的一部分,加方括号隔开。
+    text = re.sub(r'<code>([^<]*)</code>', r'[\1]', text)
     text = re.sub(r"<MdTpl[^>]*></MdTpl>", '', text)
     text = re.sub(r"<MdTpl[^>]*></MdTpl>", '', text)
     text = re.sub(r'<[^>]+>', '', text)
     text = re.sub(r'<[^>]+>', '', text)
     text = html_mod.unescape(text)
     text = html_mod.unescape(text)
@@ -398,7 +402,78 @@ def cmd_chapter(args):
     if strlen > args.warn_at:
     if strlen > args.warn_at:
         note(f'⚠ 本章约 {strlen} 字符,超过 {args.warn_at} 的提示阈值——注意上下文预算。')
         note(f'⚠ 本章约 {strlen} 字符,超过 {args.warn_at} 的提示阈值——注意上下文预算。')
 
 
-    return fetch_chapter_content(client, book, start, args)
+    if args.via == 'chapter-content':
+        return fetch_chapter_content(client, book, start, args)
+    return fetch_tipitaka_content(client, book, start, args)
+
+
+# tipitaka-content 返回的是一整串 HTML,每句包在 data-sid 里;sid 就是
+# book-para-wordStart-wordEnd,段落号从 sid 里就能取,不必解析外层的 data-para。
+SENTENCE_RE = re.compile(r"data-sid='([^']+)'\s*>(.*?)</div>", re.S)
+
+
+def fetch_tipitaka_content(client, book, para, args):
+    """整章取文:走 tipitaka-content(OpenSearch 预建文档)。
+
+    与 chapter-content 的区别:一次只取**一个** channel(参数是单数 channel),
+    返回已渲染好的 HTML 串而不是嵌套 JSON。没有该 channel 的预建文档时服务端
+    返回 400 且 message 里带 OpenSearch 的 found=false——那是「这一章没有该版本」,
+    不是服务故障,必须区分开。
+    """
+    query = {}
+    if args.channel:
+        if len(args.channel) > 1:
+            note('⚠ tipitaka-content 一次只接受一个 channel,已取第一个;'
+                 '要对读多个版本请分别调用。')
+        query['channel'] = args.channel[0]
+    try:
+        display = client.call('GET', f'v2/tipitaka-content/{book}-{para}', query=query,
+                              timeout=READ_TIMEOUT)
+    except ApiError as exc:
+        if 'found' in str(exc) and 'false' in str(exc):
+            raise WpError(
+                f'{book}:{para} 这一章没有该版本的预建内容。\n'
+                '这是「该 channel 在本章无文本」,不是服务故障——如实报告,'
+                '不要拿别的版本顶替。\n'
+                f'用 wikipali versions {book}:{para} 看这一段实际有哪些版本。'
+            )
+        raise explain_api_error(exc, f'取 {book}:{para} 的整章内容')
+
+    if not isinstance(display, str):
+        raise WpError('整章内容的返回不是字符串,服务端返回形状可能变了。')
+
+    grouped = {}
+    order = []
+    for sid, body in SENTENCE_RE.findall(display):
+        text = strip_markup(body) if args.text else re.sub(r'\s+', ' ', body).strip()
+        if not text:
+            continue
+        try:
+            para_no = int(sid.split('-')[1])
+        except (IndexError, ValueError):
+            continue
+        if para_no not in grouped:
+            grouped[para_no] = []
+            order.append(para_no)
+        grouped[para_no].append({'id': sid, 'text': text})
+    out = [{'para': n, 'sentences': grouped[n]} for n in order]
+
+    def render():
+        total = sum(len(x['sentences']) for x in out)
+        src = f'channel {args.channel[0]}' if args.channel else '巴利原文'
+        if not total:
+            print(f'\n该版本在本章**没有句子内容**(服务端返回了文档但其中没有句子)。')
+            print(f'用 wikipali versions {book}:{para} 看这一段实际有哪些版本。')
+            return
+        print(f'\n{len(out)} 段 / {total} 句({src})')
+        for item in out:
+            print(f'\n## {book}:{item["para"]}')
+            for sent in item['sentences']:
+                print(f'  {sent["id"]}  {sent["text"]}')
+        print('\n句子 id 就是引用坐标(book-para-wordStart-wordEnd)。')
+
+    emit(args, out, render)
+    return 0
 
 
 
 
 def fetch_chapter_content(client, book, para, args):
 def fetch_chapter_content(client, book, para, args):

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

@@ -178,6 +178,49 @@ nissaya 的 `html` 里每条 gloss 包在 `<MdTpl props="<base64>">` 里,base6
 整章原始返回 24 KB(仅原文)到 86 KB(带 nissaya)。只保留每句的 `id` 与正文后分别是
 整章原始返回 24 KB(仅原文)到 86 KB(带 nissaya)。只保留每句的 `id` 与正文后分别是
 3.3 KB 与 9.0 KB(**14% 与 10%**)。整章直接喂给模型是浪费,务必先过滤。
 3.3 KB 与 9.0 KB(**14% 与 10%**)。整章直接喂给模型是浪费,务必先过滤。
 
 
+## 11b. 整章内容(首选)—— `GET /v2/tipitaka-content/{book}-{para}`
+
+**取整章内容用这个,不用 `chapter-content`。** 走 OpenSearch 的预建文档
+(`tipitaka_chapter_{book}-{para}_{channelId}`),返回的 `data` 是一整串渲染好的 HTML。
+
+| | `tipitaka-content` | `chapter-content` |
+|---|---|---|
+| channel 参数 | `channel=<uuid>`(**单数,一次一个**) | `channels=<uuid,…>`(可多个)|
+| 缺省 | 巴利原文 `_System_Pali_VRI_` | 同 |
+| 返回 | 一整串 HTML | 嵌套 JSON,逐句带 origin/translation/各类计数 |
+| 用途 | 读——给人或模型看 | **写入侧要用**(需要逐句的多版本结构)|
+
+HTML 里每句包在 `data-sid='93-6-31-46'` 中,**sid 就是引用坐标**
+`book-para-wordStart-wordEnd`,段落号从 sid 里就能取,不必解析外层的 `data-para`。
+
+### 三种响应都要分开处理
+
+| 情况 | 表现 | 该怎么说 |
+|---|---|---|
+| 正常 | 200,HTML 里有 `data-sid` | — |
+| 有文档但没句子 | 200,但 `data-sid` 数为 0 | 「该版本在本章没有句子内容」 |
+| 没有该版本的预建文档 | **400**,`message` 里带 OpenSearch 的 `found:false` | 「该 channel 在本章无文本」,**不是服务故障** |
+
+第三种要靠 message 里的 `found` + `false` 判断。并非所有 channel 都有预建文档——
+实测 `93-5`:巴利原文、庄春江、北大-法胜、Punnacari 有;wbw 与 deepseek 没有。
+
+### ⚠ `<code>` 是版本页码,必须留在原位
+
+正文里夹着 `<code>M1.1</code><code>V1.1</code><code>P1.1</code><code>T1.1</code>:
+**M=缅甸版、V=VRI、P=PTS、T=泰版**。它标的是页在正文中的**起始位置**,与段落不是
+一一对应,所以**不能抽到单独的字段里**——抽走就丢了位置信息。
+
+去标签时也要留意:直接删会让页码粘到前一个词上(`Evaṃ M1.1` → `EvaṃM1.1`),看着
+像巴利词形的一部分。本项目转成 `[M1.1]` 保持可分辨。
+
+PTS 页码是西方巴利学界的标准引用依据,别丢。
+
+### 体积
+
+`93-5` 一章(7 段 37 句)原始返回 9.7 KB。这个端点的 `display` **本来就精简**,
+只有句子和最小包装,过滤后省不下多少(纯文本 8.7 KB)——与 `chapter-content` 完全
+不同,那边 24 KB 里绝大部分是每句重复的 channel/studio/editor 元数据。
+
 ## 12. 章节元信息的两个等价端点
 ## 12. 章节元信息的两个等价端点
 
 
 `GET /v2/chapter/{book}-{para}` 与 `GET /v2/palitext/{book}-{para}` **返回完全一致**
 `GET /v2/chapter/{book}-{para}` 与 `GET /v2/palitext/{book}-{para}` **返回完全一致**

+ 1 - 1
plugins/wikipali/skills/research/SKILL.md

@@ -163,7 +163,7 @@ wikipali get 216:35 216:36 216:41       # 按坐标精确取,缺省是巴利
 wikipali toc 216:512                    # 看这本书的章节结构
 wikipali toc 216:512                    # 看这本书的章节结构
 wikipali chapter 216:512                # 只报体量:章节范围、段数、字符数
 wikipali chapter 216:512                # 只报体量:章节范围、段数、字符数
 wikipali chapter 216:512 --fetch        # 确认要读全章时才加 --fetch
 wikipali chapter 216:512 --fetch        # 确认要读全章时才加 --fetch
-wikipali chapter 216:512 --fetch --channel <uid>   # 读某一个译本
+wikipali chapter 216:512 --fetch --channel <uid>   # 读某一个译本(一次一个)
 wikipali chapter 216:512 --fetch --text            # 纯文本,更省
 wikipali chapter 216:512 --fetch --text            # 纯文本,更省
 ```
 ```