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

Merge pull request #2435 from visuddhinanda/development

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

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

@@ -242,7 +242,7 @@ class ChapterContentController extends Controller
         if (count($record) === 0) {
             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';
         if (!$request->has('from')) {
             //第一次才显示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)
             ->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)
     {
         $paragraph = PaliText::where('book', $book)

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

@@ -1,5 +1,8 @@
 <?php
 
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Route;
+
 use App\Http\Controllers\AccessTokenController;
 use App\Http\Controllers\AiAssistantController;
 use App\Http\Controllers\AiModelController;
@@ -126,8 +129,8 @@ use App\Http\Controllers\WbwSentenceController;
 use App\Http\Controllers\WbwTemplateController;
 use App\Http\Controllers\WebHookController;
 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('paragraph-content', ParagraphContentController::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/completions', [MockOpenAIController::class, 'completions']);

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

@@ -1,7 +1,7 @@
 {
   "name": "wikipali",
   "description": "WikiPali 巴利三藏平台的客户端:检索与阅读语料做研究(词形展开、全文检索、出处分布、按坐标取原文与译本),以及以 AI 模型身份写入句子。",
-  "version": "0.7.0",
+  "version": "0.8.0",
   "author": {
     "name": "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('--warn-at', type=int, default=8000, help='超过多少字符就提示,默认 8000')
     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.set_defaults(func=cmd_read.cmd_chapter)
 

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

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

+ 88 - 11
plugins/wikipali/lib/cmd_read.py

@@ -37,6 +37,10 @@ def strip_markup(raw, hl='【】', bold='**'):
     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)
+    # <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'<[^>]+>', '', text)
     text = html_mod.unescape(text)
@@ -398,7 +402,78 @@ def cmd_chapter(args):
     if 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):
@@ -583,16 +658,24 @@ def cmd_count(args):
 # ---------------------------------------------------------------------------
 
 
-def terms_cache_path(lang, view):
+def cache_path(client, name):
+    """缓存文件按**站点分桶**存放。
+
+    线上四站共享同一个库,可以共用;但开发机(local)与任何自定义地址是**另一个
+    数据库**。不分桶的话,用过一次 --api local 之后,之后打线上会静默拿到开发机的
+    数据——看起来一切正常,数据却是错的。凭据早就是按桶存的,缓存同理。
+    """
     import os
+    import re as _re
     from creds import CREDS_DIR
-    return os.path.join(CREDS_DIR, 'cache', f'terms-{view}-{lang}.json')
+    bucket = _re.sub(r'[^A-Za-z0-9_.-]', '_', client.bucket_name)
+    return os.path.join(CREDS_DIR, 'cache', bucket, name)
 
 
 def cmd_terms(args):
     import os
     client = make_client(args)
-    path = terms_cache_path(args.lang, args.view)
+    path = cache_path(client, f'terms-{args.view}-{args.lang}.json')
     rows = None
     if os.path.exists(path) and not args.refresh:
         try:
@@ -798,17 +881,11 @@ def cmd_anthology(args):
 # ---------------------------------------------------------------------------
 
 
-def books_cache_path():
-    import os
-    from creds import CREDS_DIR
-    return os.path.join(CREDS_DIR, 'cache', 'book-titles.json')
-
-
 def fetch_books(client, refresh=False):
     """书目清单整表拉一次缓存在本地。服务端也缓存 24 小时,这里再缓存一层是为了
     让按 tag 筛选变成本地操作——281 条全量在手,筛什么都不用再请求。"""
     import os
-    path = books_cache_path()
+    path = cache_path(client, 'book-titles.json')
     if os.path.exists(path) and not refresh:
         try:
             with open(path, encoding='utf-8') as fh:

+ 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` 与正文后分别是
 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. 章节元信息的两个等价端点
 
 `GET /v2/chapter/{book}-{para}` 与 `GET /v2/palitext/{book}-{para}` **返回完全一致**

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

@@ -94,3 +94,8 @@ AI-汉译-Nissaya(**AI 生成**,deepseek-v3)(216:35)         ← 机器译
 内置终端按 <kbd>Ctrl</kbd>+<kbd>`</kbd> 打开(仅本地会话)。
 
 登录是**一次性**的:用户 token 有效期 365 天,同一台机器上所有副本共用这一份凭据。
+
+**本地缓存(术语表、书目清单)与凭据一样按站点分桶存放**(`~/.wikipali/cache/<桶>/`)。
+线上四站共享同一个库可以共用;开发机(`local`)与任何自定义地址是**另一个数据库**,
+不分桶的话,用过一次 `--api local` 之后打线上会静默拿到开发机的数据——看起来一切
+正常,数据却是错的。

+ 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 chapter 216:512                # 只报体量:章节范围、段数、字符数
 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            # 纯文本,更省
 ```