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

docs: 新增 WikiPali 写入型 Skill 设计文档

规划一个可复用的 Claude Code Skill,让外部项目以 AI 模型身份
通过 API 写入句子库。文档包含现有 API 盘点、端到端流程、
可行性结论、所需的服务端改动与分阶段实施计划。

核心缺口:AuthService::getUserToken() 没有 HTTP 出口,
需新增 GET /api/v2/ai-model/{uid}/token。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 2 недель назад
Родитель
Сommit
677bee64a5
1 измененных файлов с 338 добавлено и 0 удалено
  1. 338 0
      docs/wikipali-write-skill-design.md

+ 338 - 0
docs/wikipali-write-skill-design.md

@@ -0,0 +1,338 @@
+# WikiPali 写入型 Skill 设计文档
+
+> 目标:提供一个可复用的 Claude Code Skill,使**任意外部项目**都能通过 Claude Code 以「AI 模型身份」把句子写入 WikiPali 数据库(`SentenceController`),并保持正确的作者署名与权限边界。
+>
+> 状态:设计草案(未实施)
+> 对应后端:`api-v13`(Laravel 13,路由前缀 `/api/v2`)
+
+---
+
+## 1. 背景与目标
+
+现状下,只有仓库内部的组件(`ai-translate` Python worker、`app/Console/Commands/*`、`app/Services/AIAssistant/*`)能以 AI 身份写入句子库,因为它们能直接调用 `AuthService::getUserToken()` 生成模型身份 token。外部项目无此能力。
+
+本 Skill 要达成:
+
+1. 外部项目只需安装该 Skill,即可获得对 WikiPali 数据库的写入能力;
+2. 写入的句子 `editor_uid` 为 **AI 模型的 uid**(而非操作者本人),保证署名与审计正确;
+3. 权限不被放大:AI 模型只能写入「操作者本人有编辑权的 channel」,且受 `access_token` 中的 book 范围约束;
+4. 凭据管理安全、可复用,不污染用户项目仓库。
+
+### 非目标
+
+- 不提供绕过 channel 权限的写入路径;
+- 不在本期实现 wbw / sentpr / attachment 等其他资源的写入(见 §9 后续规划)。
+
+---
+
+## 2. 现有 API 盘点
+
+以下均已对照源码核实。基址记为 `{API}`,形如 `https://host/api`(参见 `ai-translate/config.orig.toml` 的 `api-url`)。
+
+### 2.1 登录 —— 可用 ✅
+
+`POST {API}/v2/sign-in`
+
+```json
+{ "username": "<用户名或邮箱>", "password": "<明文密码>" }
+```
+
+返回 `{ "ok": true, "data": "<JWT 字符串>", "message": "" }`。
+
+- 实现:`AuthController::signIn()`(`app/Http/Controllers/AuthController.php:70`)
+- JWT payload:`{nbf, exp, uid: userid, id: 主键 id}`,**有效期 365 天**(`AuthController.php:84`)
+- 校验入口:`AuthService::current()`(`app/Services/AuthService.php:43`),读取 `Authorization: Bearer <token>`
+
+`GET {API}/v2/auth/current`(Bearer)→ `data: {id, nickName, realName, avatar, token, roles}`。
+其中 `realName` 即 `user_info.username`,**后续 `studio_name` 参数要用它**(`AuthController.php:101`)。
+
+### 2.2 查询 / 创建 AI Model —— 部分可用 ⚠️
+
+`GET {API}/v2/ai-model?view=studio&name={studioName}&keyword={modelName}`(Bearer)
+
+- 实现:`AiModelController::index()`(`app/Http/Controllers/AiModelController.php:22`)
+- `view` 仅支持 `all` / `studio` / `usable` / `chat`;`keyword` 是 `like %kw%` **模糊**匹配,客户端需自行做精确 `name` 比对
+- ⚠️ 缺陷:`view` 传入非法值时 `$table` 未定义 → 500(`AiModelController.php:29-45`)
+
+`POST {API}/v2/ai-model`(Bearer)body `{name, studio_name}`
+
+- 实现:`AiModelController::store()`(`AiModelController.php:75`)
+- ⚠️ 只写入 `name / uid / real_name / owner_id / editor_id`,**不接受 `model` / `url` / `key` / `privacy` / `description`**
+- ⚠️ 无重名校验,重复调用会产生多条同名记录
+- 鉴权:`canEdit($user_uid, $studioId)` 要求 `user_uid === studioId`,即**只有个人 studio 可用,group studio 会 403**(`AiModelController.php:157`)
+
+`PUT {API}/v2/ai-model/{uid}`(Bearer)
+
+- 路由模型绑定按 `uid`(`AiModel::$primaryKey = 'uid'`)
+- ⚠️ 用 `$request->input()` 无保护地整体覆盖,**未提供的字段会被置为 null**(`AiModelController.php:125-132`)。客户端必须一次性提交全部字段
+
+### 2.3 获取 AI Model 的 user token —— **缺失** ❌
+
+`AuthService::getUserToken(string $userUid)`(`app/Services/AuthService.php:14`)会:
+- 先查 `UserApi::getByUuid`,查不到再查 `AiAssistantApi::getByUuid`(后者查 `ai_models.uid`);
+- 签发与用户 token 同构、有效期 365 天的 JWT。
+
+目前**没有任何 HTTP 路由暴露它**,调用点全在服务端内部(`AiTaskPrepare.php:112`、`AiTranslateService.php:596`、`UpgradeAITranslation.php:108` 等)。
+
+**这是本方案唯一的阻塞性缺口,必须新增端点。**
+
+### 2.4 签发 channel access token —— 可用 ✅
+
+`POST {API}/v2/access-token`(Bearer = **用户 token**)
+
+```json
+{ "payload": [ { "res_type": "channel", "res_id": "<channel uid>", "power": "edit", "book": 0 } ] }
+```
+
+返回 `data: { rows: [ { payload, token } ], count }`。
+
+- 实现:`AccessTokenController::store()`(`app/Http/Controllers/AccessTokenController.php:33`)
+- 鉴权:`ChannelApi::userCanEdit(user_uid, res_id)`,无权则该条被静默跳过(`continue 2`)→ **rows 可能为空数组,客户端必须判空**
+- 签名密钥:`AccessToken.token`(uuid)**重复两次拼接**,算法 HS512(`AccessTokenController.php:78`)
+- ⚠️ payload 中不含 `exp`,**签出的 token 永不过期**
+
+### 2.5 写入句子 —— 可用 ✅
+
+`POST {API}/v2/sentence`(Bearer = **AI model token**)
+
+```json
+{
+  "sentences": [
+    {
+      "book_id": 1,
+      "paragraph": 10,
+      "word_start": 0,
+      "word_end": 12,
+      "channel_uid": "<channel uid>",
+      "content": "译文",
+      "content_type": "markdown",
+      "access_token": "<§2.4 签出的 JWT>"
+    }
+  ]
+}
+```
+
+返回 `data: { rows: [SentResource...], count }`。
+
+- 实现:`SentenceController::store()`(`app/Http/Controllers/SentenceController.php:303`)
+- 权限判定 `UserCanEdit()`(`SentenceController.php:272`):
+  1. bearer 身份是 channel owner → 放行;
+  2. 否则查协作权限 `ShareApi::getResPower(...) >= 20` → 放行;
+  3. 否则用 `AccessToken.token` 重复两次作为密钥验签 `access_token`,并校验 book 范围。
+- 语义:按 `(book_id, paragraph, word_start, word_end, channel_uid)` 做 `firstOrNew`,**存在即更新,不存在则新建**(天然幂等)
+- 副作用:写入 `sent_history`、清 Redis 缓存、`Mq::publish('progress', ...)`
+- 另一种调用形态:把 `channel` / `book` / `access_token` 放在顶层,句子数组内不再重复(`SentenceController.php:315-326`)
+- 现成参考实现:`ai-translate/ai_translate/service.py:429`
+
+**⚠️ book 字段类型陷阱**:校验用严格比较
+`if (isset($jwt->book) && $jwt->book !== 0 && $jwt->book !== $book)`,而 `$book` 已被 `(int)` 转换。
+因此签发 access token 时 `book` **必须是整数**(`0` 表示不限 book);写成 `"1"` 字符串会导致 `"1" !== 1` 恒真而被拒绝。
+
+---
+
+## 3. 端到端流程
+
+```
+用户                Skill 脚本                     API
+ |                     |                            |
+ |-- 交互式输入口令 --->|                            |
+ |                     |-- POST /v2/sign-in ------->|
+ |                     |<-- userToken (365d) -------|
+ |                     |-- GET /v2/auth/current --->|   取 realName 作为 studio_name
+ |                     |                            |
+ |                     |-- GET /v2/ai-model?view=studio&name=&keyword= -->
+ |                     |<-- rows(精确匹配 name)---|
+ |                     |   未命中 → POST /v2/ai-model  → PUT /v2/ai-model/{uid}
+ |                     |                            |
+ |                     |-- GET /v2/ai-model/{uid}/token ★新增 -->
+ |                     |<-- modelToken (365d) ------|
+ |-- 提供 channel_id ->|                            |
+ |                     |-- POST /v2/access-token(Bearer=userToken)-->
+ |                     |<-- accessToken ------------|
+ |                     |                            |
+ |                     |-- POST /v2/sentence(Bearer=modelToken,句内带 accessToken)-->
+ |                     |<-- {rows, count} ----------|
+```
+
+三种 token 的职责必须区分清楚:
+
+| Token | 签发者 | 作用 | 用在哪 |
+|---|---|---|---|
+| userToken | `sign-in` | 代表**人类操作者** | 查/建 ai-model、签 access token |
+| modelToken | 新增端点 | 代表 **AI 模型身份** | 写句子时的 `Authorization` |
+| accessToken | `access-token` | **委托** channel 编辑权给持有者 | 写句子时的 body 字段 |
+
+---
+
+## 4. 可行性结论
+
+**可行。** 五个步骤中四步已有现成 API,剩余一步需新增约 15 行后端代码。
+
+必须做的服务端改动只有 §5.1 一项;其余为质量/安全修补,建议一并处理,因为 Skill 会高频调用这些接口,缺陷会被放大。
+
+### 备选方案(若不想改后端)
+
+用 **userToken 直接写句子**,跳过 model token。代价:
+- `editor_uid` 变成人类用户,**丧失 AI 署名与审计能力**——这与本设计的核心目的冲突;
+- 若操作者是 channel owner,连 access_token 都不需要,流程退化为两步。
+
+可作为 Skill 的降级路径(`--as-self`),但不应是默认行为。
+
+---
+
+## 5. 需要的服务端改动
+
+### 5.1 新增:获取 AI Model 的 user token(P0,阻塞)
+
+路由(`routes/api.php` v2 组内):
+
+```php
+Route::get('ai-model/{ai_model}/token', [AiModelController::class, 'token']);
+```
+
+控制器(`AiModelController`):
+
+```php
+/**
+ * 签发 AI 模型的身份 token,供外部客户端以模型身份写入数据。
+ */
+public function token(Request $request, AiModel $aiModel): JsonResponse
+{
+    $user = AuthService::current($request);
+    if (! $user) {
+        return $this->error(__('auth.failed'), 401, 401);
+    }
+    if (! StudioApi::userCanManage($user['user_uid'], $aiModel->owner_id)) {
+        return $this->error(__('auth.failed'), 403, 403);
+    }
+
+    return $this->ok([
+        'uid'   => $aiModel->uid,
+        'name'  => $aiModel->name,
+        'token' => AuthService::getUserToken($aiModel->uid),
+    ]);
+}
+```
+
+要点:
+- 鉴权用 `StudioApi::userCanManage()` 而非 `AiModelController::canEdit()`,以支持 group studio;
+- 该 token 等价于一个 365 天的模型身份凭据,**应记入 ops 日志**(参考 `App\Tools\OpsLog`);
+- 建议后续支持 `?ttl=` 缩短有效期(当前 `getUserToken` 硬编码 365 天,`AuthService.php:21`)。
+
+### 5.2 修补(P1,强烈建议)
+
+| # | 位置 | 问题 | 建议 |
+|---|---|---|---|
+| a | `AiModelController::show()` (`:102`) | **完全没有鉴权**,任何人可读任意模型 | 加 `AuthService::current` + 权限判定 |
+| b | `AiModelResource::toArray()` | `parent::toArray()` 把 `key`(第三方 API key)原样返回 | 白名单字段,移除 `key` / `system_prompt`;仅 owner 请求时才附带 |
+| c | `AiModelController::index()` (`:29-45`) | 非法 `view` → `$table` 未定义 → 500 | `default:` 分支返回 400 |
+| d | `AiModelController::store()` (`:75`) | 不接受完整字段;无重名校验 | 接受 `model/url/key/privacy/description`;同 studio 内 `name` 唯一(`firstOrCreate` + 唯一索引) |
+| e | `AiModelController::update()` (`:125`) | 未提供字段被置 null | 改用 `$request->filled()` / `only()` 增量更新 |
+| f | `AccessTokenController::store()` (`:78`) | 签出的 token **永不过期** | payload 注入 `exp`(如 7 天),并在 `UserCanEdit` 中校验 |
+| g | `AiModelController` 的 `Store/UpdateAiModelRequest` | `rules()` 为空,无任何校验 | 补 `name` 必填、`privacy` in 枚举等 |
+
+**(b) 和 (f) 是真实的安全问题,应优先于 Skill 本身完成。**
+
+---
+
+## 6. Skill 设计
+
+### 6.1 目录结构
+
+```
+wikipali-write/
+├── SKILL.md              # 触发条件 + 流程说明(给模型读)
+├── references/
+│   └── api.md            # 本文 §2 的精简版:端点、字段、陷阱
+└── scripts/
+    ├── wp_login.py       # 交互式登录,仅此脚本接触密码
+    └── wp.py             # 客户端:ensure-model / grant / write
+```
+
+选 Python 是为了与既有 `ai-translate` 保持一致(同一套请求格式,便于复用与对照)。
+
+### 6.2 凭据存储
+
+路径:`~/.wikipali/credentials.json`(**不放在用户项目目录内**,避免被误提交),权限 `0600`。
+
+```json
+{
+  "api_url": "https://.../api",
+  "user": { "uid": "...", "username": "...", "token": "..." },
+  "model": { "uid": "...", "name": "claude-opus-5", "token": "..." },
+  "access_tokens": {
+    "<channel_uid>": { "token": "...", "book": 0, "granted_at": "2026-08-03T10:00:00Z" }
+  }
+}
+```
+
+原则:
+- **Claude 永不接触明文密码**。登录由用户自己执行 `python scripts/wp_login.py`(`getpass` 读取),或在 Claude Code 中用 `! python .../wp_login.py` 前缀运行;
+- Skill 读取凭据文件时只取 token,不回显到对话中(日志里 token 一律打码);
+- 任一 token 收到 401 → 提示重新登录,而不是自动重试。
+
+### 6.3 幂等 model 记录
+
+`name` 取当前模型标识(如 `claude-opus-5`),流程:
+
+1. `GET /v2/ai-model?view=studio&name={username}&keyword={modelName}`;
+2. 在 `rows` 中做 `name` **精确**比对;
+3. 命中 → 用其 `uid`;未命中 → `POST` 创建,再 `PUT` 补齐 `model` / `privacy` 等字段(在 §5.2d 落地前必须这么做两步);
+4. `GET /v2/ai-model/{uid}/token` 取 modelToken,写入凭据文件缓存。
+
+### 6.4 写入前的确认
+
+写库属于对外的、不易回滚的操作。Skill 必须在 `POST /v2/sentence` 之前:
+- 展示:目标 channel(uid + name)、book、句子条数、前若干条的 `id` 与 content 摘要;
+- 明确提示「已存在的相同句子将被覆盖」(`firstOrNew` 语义);
+- 取得用户确认后才发送。批量写入建议分批(如每批 50 条)并报告累计 `count`。
+
+### 6.5 错误处理约定
+
+| 现象 | 含义 | 处置 |
+|---|---|---|
+| 401 | token 失效/过期 | 引导重新登录,勿自动重试 |
+| 403 | 无 channel 编辑权,或非 studio owner | 明确指出缺哪一项权限 |
+| `access-token` 返回 `count: 0` | 用户对该 channel 无编辑权(被静默跳过) | 当作 403 报错,**不可继续写入** |
+| `sentence` 返回 `count` 小于提交条数 | 部分句子鉴权失败被 `continue` 跳过 | 逐条对比返回的 rows,报告被跳过的句子 id |
+| `no date` (200) | 请求缺 `sentences` 字段 | 客户端 bug |
+
+注意 `SentenceController::store()` 对**逐句失败是静默跳过**的(`:341`),所以「HTTP 200」不等于「全部写入成功」,必须核对 `count`。
+
+---
+
+## 7. 安全考量
+
+1. **权限不放大**:AI 模型自身不是任何 channel 的 owner/协作者,其全部写权限来自用户签发的 access token,且受 book 范围限制。用户无权的 channel,签发阶段就会失败。
+2. **模型 token 是长效凭据**(365 天)。落地 §5.1 时应记 ops 日志;后续支持可撤销(例如在 `ai_models` 上加 `token_version` 参与 JWT payload,改版即失效)。
+3. **access token 永不过期**是当前实现的既有风险(§5.2f)。在修复前,Skill 应把它视为高敏感数据,仅存本地、不进日志、不进对话。
+4. **密码零留存**:不写入任何文件,不进入对话上下文。
+5. **审计**:所有写入都会进 `sent_history`(`SentenceService::saveHistory`),`editor_uid` 为模型 uid,可追溯。
+
+---
+
+## 8. 实施计划
+
+| 阶段 | 内容 | 依赖 |
+|---|---|---|
+| P0 | 服务端:新增 `GET /v2/ai-model/{uid}/token` + 测试 | — |
+| P0 | 服务端安全修补:§5.2 (a)(b)(f) | — |
+| P1 | Skill:`wp_login.py` + 凭据存储 + `auth/current` 校验 | P0 |
+| P1 | Skill:`ensure-model`(查/建/补字段/取 token) | P0 |
+| P1 | Skill:`grant`(签 access token,缓存) | — |
+| P1 | Skill:`write`(分批 + 确认 + count 核对) | 以上全部 |
+| P2 | 服务端质量修补:§5.2 (c)(d)(e)(g) | — |
+| P2 | Skill 扩展:读取能力(`GET /v2/sentence`、channel 列表、`sentences-in-chapter`)与 `sentpr` PR 提交 | P1 |
+
+测试要求(`api-v13` 使用 Pest):
+- Feature test 覆盖新端点的 401 / 403 / 200 三条路径;
+- 一条端到端 test:登录 → 建模型 → 取 model token → 签 access token → 写句子 → 断言 `editor_uid == 模型 uid`。
+
+---
+
+## 9. 待确认问题
+
+1. 模型记录挂在**个人 studio** 还是**共享 group studio** 下?前者简单(现有 `canEdit` 即可),后者更适合团队复用,但需要 §5.1 采用 `StudioApi::userCanManage`。
+2. 是否需要为「AI 模型」提供撤销 token 的机制?若需要,应在 P0 就把 `token_version` 设计进去,避免后续 JWT 结构变更。
+3. channel uid 的获取方式:用户直接提供,还是由 Skill 通过 `GET /v2/channel`(列出可编辑 channel)交互式选择?后者体验更好,需先确认该接口的 `view` 参数取值。
+4. Skill 的分发形态:随本仓库发布,还是独立成一个可 `git clone` 到 `~/.claude/skills/` 的仓库?