Compare commits

..
2 Commits
Author SHA1 Message Date
nyaKazuha 0275a890bc fix(glossary): 补齐术语删除和审核门禁
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
2026-09-08 20:59:27 +08:00
nyaKazuha 94483ff14d feat(glossary): 实现 Rust Glossary V1 2026-09-07 22:38:53 +08:00
42 changed files with 5105 additions and 97 deletions
+13 -7
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-09-06
- **更新时间**2026-09-08
- **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
@@ -38,6 +38,12 @@ job/unit/provider run 状态;`translation.memory.summary/query/confirm` 提供
Rust-owned TM 的摘要、source/context 查询和显式 trusted 确认,`bat-api` 仅作
typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取
任务,最终资源报告按 pull plan 顺序输出。
项目级 Glossary V1 位于 `<output>/glossary.sqlite`,独立于 release task 和 TM
Rust `bat` 持有 term/alias/recommended/allowed/category/priority、全局或
TextUnit scope、source history 和 approved review。worker、TM 复用、人工 task
结果和 workbench publish 都执行确定性 QAblocking deviation 必须携带
reviewer/reason/provenance 的显式 override。`translation.glossary.*` 已通过
`bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。
---
@@ -88,7 +94,7 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成
待完成:
- 领域服务模块仍为空。
- Glossary、Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
- Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
- 公共错误模型需要与 CLI/API 错误码统一。
### `bat-adapters`
@@ -149,7 +155,7 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成
待完成:
- 基于已接入的 `translation.worker.run` 继续推进 Glossary、完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM 查询和通用 Patch 发布资源视图。
- 基于已接入的 `translation.worker.run` 继续推进完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM/Glossary 查询和通用 Patch 发布资源视图。
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`;实际运行报告由脚本写入隔离输出目录。
- 增加更多权限和极端文件系统场景测试。
@@ -272,7 +278,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--watch
```
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` contract fixtureGo mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移;TM 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``localized.publish``localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` 和 Glossary query contract fixtureGo mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.*``localized.publish``localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
生产要求:
@@ -299,7 +305,7 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
2. 继续通用 Patchmanifest 驱动、双 release 查询和清理策略。
3. 继续资源查询和翻译基础设施:更丰富的查询、Glossary 和 Provider
3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider
扩展体系。
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
@@ -310,6 +316,6 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
`backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;
完整 AssetBundle 重打包、完整 Web 协作后台、Glossary、模糊 TM 匹配和通用 manifest 发布未完成。
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、Glossary、通用 manifest Patch
完整 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和通用 manifest 发布未完成。
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、通用 manifest Patch
构建,以及真实官方资源长期运行验证。
+6 -6
View File
@@ -261,8 +261,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
交付物:
1. Translation Memory V1 已使用项目级 SQLite schemasource raw/hash、translation、完整 context、candidate/trusted 和 provenance。
2. 已实现 raw source + 完整 context exact match;模糊匹配、Glossary 联动和完整导入导出仍待实现。
3. 已实现显式 per-record confirm;术语优先级、别名、分类、冲突检测和审核队列仍待实现。
2. 已实现 raw source + 完整 context exact match;模糊匹配和完整导入导出仍待实现。Glossary V1 已作为独立项目级 SQLite 资产接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override
3. 已实现显式 per-record confirmGlossary V1 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
4. 已实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC。
验收标准:
@@ -387,7 +387,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是 Glossary、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
建议顺序:
@@ -405,7 +405,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
`docs/reports/GO_STATUS.md`
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。
2. 基于 `translation.worker.run` 继续推进 Glossary 和 Patch 构建。
2. 基于 `translation.worker.run` 继续推进 Patch 构建。
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。
@@ -461,9 +461,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM 扩展、Glossary、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。
---
- **下一份应补充的验证材料**:真实官方网络 smoke 运行记录
- **下一项工程任务**:推进 TM 扩展、Glossary、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
- **下一项工程任务**:推进 TM/Glossary 扩展、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
+4 -4
View File
@@ -13,14 +13,14 @@
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`report 按 plan 顺序、进度按完成数单调上报)、已发布历史 release 与 CAS 复用、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json``--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*``resource.*``parse.*``translation.tasks/handoff/task.update/worker.run``translation.memory.*``localized.status``catalog.*``task.*``patch.apply``unityfs.patch_*`)。
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`report 按 plan 顺序、进度按完成数单调上报)、已发布历史 release 与 CAS 复用、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json``--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*``resource.*``parse.*``translation.tasks/handoff/task.update/worker.run``translation.memory.*``translation.glossary.*``localized.status``catalog.*``task.*``patch.apply``unityfs.patch_*`)。
- `internal/backendrpc`Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVPG-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation/TM admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询索引,常用 metadata 过滤会下推到 SQLite;历史 release 复用会重新校验 size、BLAKE3 和 ZIP 结构,失败时按历史 release、CAS、网络顺序回退,CAS 引用记录在 `official-cas-reuse-references.json` 中;`bat doctor cas` 可只读诊断既有 CAS 目录、对象数、对象字节数和元数据库文件状态。
- 新 release 发布后会生成 `official-resource-changes.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-translation-handoff.json``crowdin-textunit-queue.json``translation-tasks.sqlite``translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库Translation Memory V1TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。Glossary、模糊匹配和完整 Provider 扩展体系仍待实现。
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库Translation Memory V1 和 Glossary V1Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
@@ -31,7 +31,7 @@
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。
- Translation Memory、Glossary 和完整 Provider 扩展体系:其中 Translation Memory V1 已由 Rust `bat` 持有;仍未实现的是 Glossary、模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V1 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
- SDK、完整 Web 协作后台。
详细状态见:
@@ -191,7 +191,7 @@ BlueArchiveToolkit/
1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard`cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,不应误写成完整产品 CLI。
2. 补齐 AssetBundle UnityFS 引擎级解析。
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
4. 基于 `translation.worker.run` provider worker 扩展 Glossary、完整 Patch 构建和发布/回滚闭环。
4. 基于 `translation.worker.run` provider worker 继续推进完整 Patch 构建和发布/回滚闭环。
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在 TM 扩展、权限模型和持久化 API 明确后推进。
+39 -1
View File
@@ -152,6 +152,9 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
| `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token |
| `GET /admin/translation/memory/summary` | 读取 Rust TM schema、记录总数及 candidate/trusted 等状态计数;需要管理 token |
| `GET /admin/translation/memory/query?source_text=...&source_context=...&limit=100` | 按 raw source/context 查询 Rust TM 记录、复用判定和 provenance;需要管理 token |
| `GET /admin/translation/glossary/summary` | 读取 Rust Glossary schema 和 review-state 计数;需要管理 token |
| `GET /admin/translation/glossary/query?source_text=...&review_status=approved&limit=100` | 查询 Rust term、scope、source provenance 和 history;需要管理 token |
| `GET /admin/translation/glossary/diagnose?source_text=...&context=...` | 执行 deterministic Glossary constraints/diagnostics QA;需要管理 token |
| `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token |
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
@@ -187,6 +190,11 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
| `translation-memory-confirm` | `translation.memory.confirm` | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | `202` + 已确认的 TM 记录 |
| `translation-glossary-add` | `translation.glossary.add` | term draft JSON | `202` + Glossary term |
| `translation-glossary-update` | `translation.glossary.update` | term draft + `reviewer` | `202` + Glossary term |
| `translation-glossary-approve` | `translation.glossary.approve` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + approved term |
| `translation-glossary-deprecate` | `translation.glossary.deprecate` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deprecated term |
| `translation-glossary-delete` | `translation.glossary.delete` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deleted term snapshot |
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest |
| `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
@@ -286,7 +294,7 @@ curl -i -H 'Range: bytes=0-1023' \
- `config.toml` 的字段按职责分组:`[runtime]``[resource]``[localized]``[repository]``[network]``[translation.worker]`
- 现有 `BAT_*` 环境变量仍然有效,可继续覆盖 `config.toml` 中的同名配置。
- `BAT_SKIP_ENV_FILE` 已废弃且不再影响启动。
- 支持的环境变量:`BAT_OUTPUT``BAT_LOCALIZED_OUTPUT``BAT_STATE_DIR``BAT_AUTO_DISCOVER``BAT_WATCH``BAT_DAEMON``BAT_IMPORT_REPOSITORY``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB``BAT_PROXY``BAT_NO_PROXY``BAT_INTERVAL_SECONDS``BAT_ERROR_RETRY_SECONDS``BAT_APP_VERSION``BAT_CONNECTION_GROUP``BAT_LAUNCHER_VERSION``BAT_PLATFORMS``BAT_CURL``BAT_DOWNLOAD_CONCURRENCY``BAT_UNZIP``BAT_JSON``BAT_QUIET_UP_TO_DATE``BAT_TRANSLATION_PROVIDER``BAT_TRANSLATION_FIXTURE``BAT_TRANSLATION_MEMORY_PATH``BAT_TRANSLATION_CONCURRENCY``BAT_TRANSLATION_MAX_ATTEMPTS``BAT_TRANSLATION_LEASE_SECONDS``BAT_TRANSLATION_RETRY_BACKOFF_SECONDS``BAT_TRANSLATION_MAX_TASKS``BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`
- 支持的环境变量:`BAT_OUTPUT``BAT_LOCALIZED_OUTPUT``BAT_STATE_DIR``BAT_AUTO_DISCOVER``BAT_WATCH``BAT_DAEMON``BAT_IMPORT_REPOSITORY``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB``BAT_PROXY``BAT_NO_PROXY``BAT_INTERVAL_SECONDS``BAT_ERROR_RETRY_SECONDS``BAT_APP_VERSION``BAT_CONNECTION_GROUP``BAT_LAUNCHER_VERSION``BAT_PLATFORMS``BAT_CURL``BAT_DOWNLOAD_CONCURRENCY``BAT_UNZIP``BAT_JSON``BAT_QUIET_UP_TO_DATE``BAT_TRANSLATION_PROVIDER``BAT_TRANSLATION_FIXTURE``BAT_TRANSLATION_MEMORY_PATH``BAT_GLOSSARY_PATH``BAT_TRANSLATION_CONCURRENCY``BAT_TRANSLATION_MAX_ATTEMPTS``BAT_TRANSLATION_LEASE_SECONDS``BAT_TRANSLATION_RETRY_BACKOFF_SECONDS``BAT_TRANSLATION_MAX_TASKS``BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时运行模式设置让位。`status` / `verify` 等子命令不受它们影响。
- 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`
@@ -311,6 +319,36 @@ RPC `translation.memory.summary`、`translation.memory.query`、`translation.mem
context 不完整或不一致、normalized source 辅助命中和 workflow `proofread` 都不会自动
建立 trusted 记录。
### Glossary V1
Glossary 由 Rust `bat` 独立持有,默认路径为 `<output>/glossary.sqlite`,不位于
`versions/<id>`,也不与 TM 或当前 release 的 task 库共用。配置覆盖方式为
`[translation.worker].glossary_path``BAT_GLOSSARY_PATH`
`--glossary-path`。缺少数据库时 summary/query/diagnose 返回 `available=false`
不会因只读查询创建空库。
```bash
bat i18n glossary summary
bat i18n glossary add --glossary-term-id term-sensei \
--glossary-source-term Sensei \
--glossary-recommended-translation 老师 \
--glossary-allowed-translations-json '["老师大人"]' \
--glossary-source-kind manual --glossary-source-ref issue-123
bat i18n glossary approve --glossary-term-id term-sensei \
--glossary-reviewer operator --glossary-reason '术语审校通过'
bat i18n glossary diagnose --glossary-source-text 'Sensei' \
--glossary-context-json '{"destination":"Table.bytes"}'
bat i18n glossary delete --glossary-term-id term-sensei \
--glossary-reviewer operator --glossary-reason '重复术语'
```
只有 `approved` term 会进入 provider constraints 和 TM 自动复用前的 QA。
scope 为空表示全局;同一 TextUnit 内冲突会 blockedpriority、scope specificity、
匹配长度和 term ID 使用确定性排序。允许但非推荐译法只产生 warning,系统不会在译文
生成后自动替换文本。provider、TM、人工 task update、workbench 和 localized publish
均执行相同 QAblocking deviation 需要 `reviewer``reason``provenance` 和确认时间
组成显式 override。
---
## 4. 退出码
+145 -1
View File
@@ -454,6 +454,86 @@ paths:
description: Missing or invalid admin token.
"503":
description: Rust bat Translation Memory backend is unavailable.
/admin/translation/glossary/summary:
get:
summary: Read Rust-owned Glossary summary
parameters:
- name: glossary_path
in: query
schema:
type: string
responses:
"200":
description: Glossary availability and review-state counts.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Glossary backend is unavailable.
/admin/translation/glossary/query:
get:
summary: Query Rust-owned Glossary terms
parameters:
- name: source_text
in: query
schema:
type: string
- name: category
in: query
schema:
type: string
- name: review_status
in: query
schema:
type: string
enum: [draft, approved, deprecated, rejected]
- name: limit
in: query
schema:
type: integer
format: int64
minimum: 1
maximum: 1000
default: 100
- name: glossary_path
in: query
schema:
type: string
responses:
"200":
description: Glossary terms with source and review history.
"400":
description: Invalid Glossary query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Glossary backend is unavailable.
/admin/translation/glossary/diagnose:
get:
summary: Run deterministic Glossary diagnostics
parameters:
- name: source_text
in: query
required: true
schema:
type: string
- name: context
in: query
description: JSON object whose values are strings.
schema:
type: string
- name: glossary_path
in: query
schema:
type: string
responses:
"200":
description: Glossary constraints, diagnostics, and blocked decision.
"400":
description: Missing source text or invalid context.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Glossary backend is unavailable.
/admin/translation/status:
get:
summary: Read Rust-owned localized release status
@@ -473,7 +553,7 @@ paths:
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, localized-publish, localized-rollback]
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback]
requestBody:
required: false
content:
@@ -539,6 +619,21 @@ paths:
type: string
translated_text:
type: string
glossary_override:
type: object
required: [reviewer, reason, provenance, confirmed_unix_seconds]
additionalProperties: false
properties:
reviewer:
type: string
reason:
type: string
provenance:
type: string
confirmed_unix_seconds:
type: integer
format: int64
minimum: 1
fixture_path:
type: string
concurrency:
@@ -566,8 +661,57 @@ paths:
type: string
translation_memory_path:
type: string
glossary_path:
type: string
record_id:
type: string
term_id:
type: string
source_term:
type: string
aliases:
type: array
items:
type: string
recommended_translation:
type: string
allowed_translations:
type: array
items:
type: string
source_language:
type: string
target_language:
type: string
category:
type: string
priority:
type: integer
format: int64
scope:
type: object
additionalProperties:
type: string
review_status:
type: string
enum: [draft, approved, deprecated, rejected]
source:
type: object
additionalProperties: false
required: [source_kind, observed_unix_seconds]
properties:
source_kind:
type: string
enum: [manual, imported]
source_ref:
type: string
source_author:
type: string
source_note:
type: string
observed_unix_seconds:
type: integer
format: int64
reviewer:
type: string
reason:
+729
View File
@@ -0,0 +1,729 @@
//! Glossary V1 domain objects and deterministic term QA.
use std::collections::{BTreeMap, BTreeSet};
/// Glossary review state. Only approved terms participate in automation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GlossaryReviewStatus {
/// Imported or manually entered term awaiting review.
Draft,
/// Term explicitly approved for provider/TM automation.
Approved,
/// Term retained for history but no longer active.
Deprecated,
/// Term rejected by review.
Rejected,
}
impl GlossaryReviewStatus {
/// Stable persistence label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Approved => "approved",
Self::Deprecated => "deprecated",
Self::Rejected => "rejected",
}
}
/// Parses a stable persistence label.
pub fn parse(value: &str) -> Option<Self> {
match value {
"draft" => Some(Self::Draft),
"approved" => Some(Self::Approved),
"deprecated" => Some(Self::Deprecated),
"rejected" => Some(Self::Rejected),
_ => None,
}
}
/// Whether this term is eligible for automatic application.
pub const fn is_approved(self) -> bool {
matches!(self, Self::Approved)
}
}
/// Origin of a glossary term.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GlossarySourceKind {
/// Explicitly entered by a maintainer or reviewer.
Manual,
/// Imported from an external glossary artifact.
Imported,
}
impl GlossarySourceKind {
/// Stable persistence label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Imported => "imported",
}
}
/// Parses a stable persistence label.
pub fn parse(value: &str) -> Option<Self> {
match value {
"manual" => Some(Self::Manual),
"imported" => Some(Self::Imported),
_ => None,
}
}
}
/// Provenance of the current term definition.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossarySourceRecord {
/// Source classification.
pub source_kind: GlossarySourceKind,
/// Stable source reference, such as an import file or issue.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_ref: Option<String>,
/// Person or system that supplied the source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_author: Option<String>,
/// Source note.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_note: Option<String>,
/// Time this source was observed.
pub observed_unix_seconds: u64,
}
/// One historical glossary mutation.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryHistoryRecord {
/// Stable history row ID.
pub history_id: String,
/// Mutation action (`created`, `updated`, `approved`, ...).
pub action: String,
/// Reviewer responsible for the mutation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewer: Option<String>,
/// Human reason for the mutation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// Snapshot of the source provenance at that point.
pub source: GlossarySourceRecord,
/// Review state after the mutation.
pub review_status: GlossaryReviewStatus,
/// Complete term snapshot, excluding history.
pub snapshot: GlossaryTermSnapshot,
/// Mutation time.
pub observed_unix_seconds: u64,
}
/// Serializable term data captured in source history.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryTermSnapshot {
/// Source-language term.
pub source_term: String,
/// Alternative source-language spellings.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
/// Recommended target translation.
pub recommended_translation: String,
/// Other target translations explicitly allowed by review.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_translations: Vec<String>,
/// Optional source language.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_language: Option<String>,
/// Optional target language.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_language: Option<String>,
/// Optional category.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Higher values win an explicit local conflict.
pub priority: i32,
/// Empty scope means global.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub scope: BTreeMap<String, String>,
}
/// A project-level approved or historical glossary term.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryTerm {
/// Stable term ID.
pub term_id: String,
/// Current definition.
#[serde(flatten)]
pub definition: GlossaryTermSnapshot,
/// Current review state.
pub review_status: GlossaryReviewStatus,
/// Current source provenance.
pub source: GlossarySourceRecord,
/// Full source/review history.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<GlossaryHistoryRecord>,
/// Creation time.
pub created_unix_seconds: u64,
/// Last mutation time.
pub updated_unix_seconds: u64,
}
impl GlossaryTerm {
/// Returns a snapshot suitable for history persistence.
pub fn snapshot(&self) -> GlossaryTermSnapshot {
self.definition.clone()
}
}
/// Input used to create or replace a term definition.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryTermDraft {
/// Stable term ID.
pub term_id: String,
/// Current definition.
#[serde(flatten)]
pub definition: GlossaryTermSnapshot,
/// Initial/current review state.
pub review_status: GlossaryReviewStatus,
/// Current source provenance.
pub source: GlossarySourceRecord,
}
/// Counts of glossary terms by review state.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossarySummary {
/// SQLite schema version.
pub schema_version: u32,
/// Total term count.
pub term_count: u64,
/// Approved term count.
pub approved_count: u64,
/// Draft term count.
pub draft_count: u64,
/// Deprecated term count.
pub deprecated_count: u64,
/// Rejected term count.
pub rejected_count: u64,
}
/// Provider-neutral glossary constraint attached to a TextUnit.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryConstraint {
/// Stable matched term ID.
pub term_id: String,
/// Source spelling found in the TextUnit.
pub matched_source: String,
/// Recommended target translation.
pub recommended_translation: String,
/// Explicitly allowed target translations.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_translations: Vec<String>,
/// Optional category.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Term priority.
pub priority: i32,
/// Matching scope.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub scope: BTreeMap<String, String>,
}
/// Deterministic diagnostic kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GlossaryDiagnosticKind {
/// Equal-precedence terms require an explicit decision.
Conflict,
/// A lower-precedence term was explicitly overridden by a higher one.
Overridden,
/// The output contains neither the recommended nor an allowed translation.
Violation,
/// The output uses an allowed but non-recommended translation.
NonRecommended,
}
impl GlossaryDiagnosticKind {
/// Stable diagnostic label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Conflict => "conflict",
Self::Overridden => "overridden",
Self::Violation => "violation",
Self::NonRecommended => "non_recommended",
}
}
}
/// One glossary matching or QA diagnostic.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryDiagnostic {
/// Diagnostic kind.
pub kind: GlossaryDiagnosticKind,
/// Term ID, when tied to one term.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub term_id: Option<String>,
/// Source spelling or target text involved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
/// Stable human-readable detail.
pub message: String,
}
/// Result of applying approved terms to one TextUnit source.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryEvaluation {
/// Approved constraints sent to a provider.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraints: Vec<GlossaryConstraint>,
/// Matching diagnostics, including explicit overrides and conflicts.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<GlossaryDiagnostic>,
/// Whether a provider/TM result must not be automatically accepted.
pub blocked: bool,
}
impl GlossaryEvaluation {
/// Runs deterministic output QA against the approved constraints.
pub fn check_translation(&self, translated_text: &str) -> GlossaryQaReport {
let mut diagnostics = self.diagnostics.clone();
let mut blocked = self.blocked;
for constraint in &self.constraints {
let mut accepted = vec![(constraint.recommended_translation.as_str(), true)];
accepted.extend(
constraint
.allowed_translations
.iter()
.map(|value| (value.as_str(), false)),
);
accepted.sort_by(|left, right| {
right
.0
.len()
.cmp(&left.0.len())
.then_with(|| left.0.cmp(right.0))
});
if let Some((value, recommended)) = accepted
.into_iter()
.find(|(value, _)| !value.is_empty() && translated_text.contains(value))
{
if !recommended {
diagnostics.push(GlossaryDiagnostic {
kind: GlossaryDiagnosticKind::NonRecommended,
term_id: Some(constraint.term_id.clone()),
value: Some(value.to_string()),
message: format!(
"TextUnit 使用了术语 {} 的允许但非推荐译法",
constraint.matched_source
),
});
}
} else {
blocked = true;
diagnostics.push(GlossaryDiagnostic {
kind: GlossaryDiagnosticKind::Violation,
term_id: Some(constraint.term_id.clone()),
value: Some(constraint.matched_source.clone()),
message: format!(
"TextUnit 中的术语 {} 未使用推荐或允许译法",
constraint.matched_source
),
});
}
}
GlossaryQaReport {
status: if blocked {
GlossaryQaStatus::Blocked
} else if diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::NonRecommended)
{
GlossaryQaStatus::Warning
} else {
GlossaryQaStatus::Pass
},
constraints: self.constraints.clone(),
diagnostics,
}
}
}
/// QA status of a concrete translation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GlossaryQaStatus {
/// Every matched term uses the recommendation.
Pass,
/// An explicitly allowed alternative was used.
Warning,
/// A conflict or unapproved translation requires review.
Blocked,
}
impl GlossaryQaStatus {
/// Stable status label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Warning => "warning",
Self::Blocked => "blocked",
}
}
/// Whether publication requires explicit human confirmation.
pub const fn is_blocked(self) -> bool {
matches!(self, Self::Blocked)
}
}
/// Persisted glossary QA attached to a translation result.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryQaReport {
/// QA status.
pub status: GlossaryQaStatus,
/// Constraints evaluated.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraints: Vec<GlossaryConstraint>,
/// Deterministic diagnostics.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<GlossaryDiagnostic>,
}
/// Explicit human approval to deviate from a blocking glossary result.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryOverride {
/// Reviewer identity.
pub reviewer: String,
/// Required reason.
pub reason: String,
/// Provenance of the confirmation action.
pub provenance: String,
/// Confirmation time.
pub confirmed_unix_seconds: u64,
}
/// Applies only approved terms to one source/context pair.
pub fn evaluate_glossary(
terms: &[GlossaryTerm],
source_text: &str,
context: &BTreeMap<String, String>,
) -> GlossaryEvaluation {
#[derive(Clone)]
struct Candidate {
term_id: String,
matched_source: String,
recommendation: String,
allowed: Vec<String>,
category: Option<String>,
priority: i32,
scope: BTreeMap<String, String>,
start: usize,
end: usize,
specificity: usize,
}
let mut candidates = Vec::new();
for term in terms.iter().filter(|term| term.review_status.is_approved()) {
if !term
.definition
.scope
.iter()
.all(|(key, value)| context.get(key) == Some(value))
{
continue;
}
let mut spellings = vec![term.definition.source_term.clone()];
spellings.extend(term.definition.aliases.clone());
spellings.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
spellings.dedup();
for spelling in spellings.into_iter().filter(|value| !value.is_empty()) {
for (start, _) in source_text.match_indices(&spelling) {
candidates.push(Candidate {
term_id: term.term_id.clone(),
matched_source: spelling.clone(),
recommendation: term.definition.recommended_translation.clone(),
allowed: term.definition.allowed_translations.clone(),
category: term.definition.category.clone(),
priority: term.definition.priority,
scope: term.definition.scope.clone(),
start,
end: start + spelling.len(),
specificity: term.definition.scope.len(),
});
}
}
}
candidates.sort_by(|left, right| {
right
.priority
.cmp(&left.priority)
.then_with(|| right.specificity.cmp(&left.specificity))
.then_with(|| (right.end - right.start).cmp(&(left.end - left.start)))
.then_with(|| left.start.cmp(&right.start))
.then_with(|| left.term_id.cmp(&right.term_id))
.then_with(|| left.matched_source.cmp(&right.matched_source))
});
let mut selected = Vec::new();
let mut diagnostics = Vec::new();
let mut blocked = false;
for candidate in candidates {
let overlapping = selected.iter().find(|selected: &&Candidate| {
candidate.start < selected.end && selected.start < candidate.end
});
if let Some(selected) = overlapping {
let same_precedence = candidate.priority == selected.priority
&& candidate.specificity == selected.specificity
&& candidate.end - candidate.start == selected.end - selected.start;
if candidate.recommendation != selected.recommendation {
if same_precedence {
blocked = true;
diagnostics.push(GlossaryDiagnostic {
kind: GlossaryDiagnosticKind::Conflict,
term_id: Some(candidate.term_id.clone()),
value: Some(candidate.matched_source.clone()),
message: format!(
"术语 {} 与 {} 在同一 TextUnit 位置产生不同推荐译法",
selected.matched_source, candidate.matched_source
),
});
} else {
diagnostics.push(GlossaryDiagnostic {
kind: GlossaryDiagnosticKind::Overridden,
term_id: Some(candidate.term_id.clone()),
value: Some(candidate.matched_source.clone()),
message: format!(
"术语 {} 被优先级更高或范围更具体的术语覆盖",
candidate.matched_source
),
});
}
}
continue;
}
selected.push(candidate);
}
selected.sort_by(|left, right| {
left.start
.cmp(&right.start)
.then_with(|| left.term_id.cmp(&right.term_id))
.then_with(|| left.matched_source.cmp(&right.matched_source))
});
let mut seen = BTreeSet::new();
let constraints = selected
.into_iter()
.filter(|candidate| {
seen.insert((
candidate.term_id.clone(),
candidate.matched_source.clone(),
candidate.start,
))
})
.map(|candidate| GlossaryConstraint {
term_id: candidate.term_id,
matched_source: candidate.matched_source,
recommended_translation: candidate.recommendation,
allowed_translations: candidate.allowed,
category: candidate.category,
priority: candidate.priority,
scope: candidate.scope,
})
.collect();
GlossaryEvaluation {
constraints,
diagnostics,
blocked,
}
}
/// Validates and normalizes a term draft without choosing a review status.
pub fn validate_glossary_draft(draft: &GlossaryTermDraft) -> crate::Result<()> {
if draft.term_id.trim().is_empty()
|| draft.definition.source_term.trim().is_empty()
|| draft.definition.recommended_translation.trim().is_empty()
{
return Err(crate::Error::InvalidArgument(
"Glossary term_id、source_term 和 recommended_translation 不能为空".to_string(),
));
}
if draft
.definition
.allowed_translations
.iter()
.any(|value| value.trim().is_empty())
{
return Err(crate::Error::InvalidArgument(
"Glossary allowed_translations 不能包含空字符串".to_string(),
));
}
if draft
.definition
.aliases
.iter()
.any(|value| value.trim().is_empty())
{
return Err(crate::Error::InvalidArgument(
"Glossary aliases 不能包含空字符串".to_string(),
));
}
let accepted = draft
.definition
.allowed_translations
.iter()
.chain(std::iter::once(&draft.definition.recommended_translation));
if draft.definition.recommended_translation.trim().is_empty()
|| accepted.clone().any(|value| value.trim().is_empty())
{
return Err(crate::Error::InvalidArgument(
"Glossary translation 不能包含空字符串".to_string(),
));
}
if draft
.definition
.scope
.iter()
.any(|(key, value)| key.trim().is_empty() || value.trim().is_empty())
{
return Err(crate::Error::InvalidArgument(
"Glossary scope 的键和值不能为空".to_string(),
));
}
if draft.source.observed_unix_seconds == 0 {
return Err(crate::Error::InvalidArgument(
"Glossary source observed_unix_seconds 必须大于 0".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn term(
term_id: &str,
source: &str,
translation: &str,
priority: i32,
scope: BTreeMap<String, String>,
) -> GlossaryTerm {
GlossaryTerm {
term_id: term_id.to_string(),
definition: GlossaryTermSnapshot {
source_term: source.to_string(),
aliases: Vec::new(),
recommended_translation: translation.to_string(),
allowed_translations: Vec::new(),
source_language: None,
target_language: None,
category: None,
priority,
scope,
},
review_status: GlossaryReviewStatus::Approved,
source: GlossarySourceRecord {
source_kind: GlossarySourceKind::Manual,
source_ref: None,
source_author: Some("test".to_string()),
source_note: None,
observed_unix_seconds: 1,
},
history: Vec::new(),
created_unix_seconds: 1,
updated_unix_seconds: 1,
}
}
#[test]
fn only_approved_terms_and_matching_scopes_are_constraints() {
let mut local = BTreeMap::new();
local.insert("destination".to_string(), "story".to_string());
let mut draft = term("draft", "Sensei", "老师", 10, BTreeMap::new());
draft.review_status = GlossaryReviewStatus::Draft;
let terms = vec![
draft,
term("global", "Blue Archive", "蔚蓝档案", 1, BTreeMap::new()),
term("local", "Sensei", "老师", 2, local.clone()),
];
let evaluation = evaluate_glossary(&terms, "Blue Archive Sensei", &local);
assert_eq!(evaluation.constraints.len(), 2);
assert!(evaluation
.constraints
.iter()
.any(|constraint| constraint.term_id == "global"));
assert!(evaluation
.constraints
.iter()
.any(|constraint| constraint.term_id == "local"));
}
#[test]
fn equal_precedence_conflicts_block_automatic_use() {
let terms = vec![
term("a", "Sensei", "老师", 1, BTreeMap::new()),
term("b", "Sensei", "导师", 1, BTreeMap::new()),
];
let evaluation = evaluate_glossary(&terms, "Sensei", &BTreeMap::new());
assert!(evaluation.blocked);
assert!(evaluation
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Conflict));
}
#[test]
fn allowed_alternative_is_warning_but_unknown_translation_is_blocked() {
let mut term = term("a", "Sensei", "老师", 1, BTreeMap::new());
term.definition.allowed_translations = vec!["导师".to_string(), "老师大人".to_string()];
let evaluation = evaluate_glossary(&[term.clone()], "Sensei", &BTreeMap::new());
let warning = evaluation.check_translation("导师");
assert_eq!(warning.status, GlossaryQaStatus::Warning);
let longer_warning = evaluation.check_translation("老师大人");
assert_eq!(longer_warning.status, GlossaryQaStatus::Warning);
let blocked = evaluation.check_translation("先生");
assert_eq!(blocked.status, GlossaryQaStatus::Blocked);
assert!(blocked
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Violation));
}
#[test]
fn higher_priority_term_wins_even_when_it_starts_later() {
let terms = vec![
term("low", "Blue Archive", "蔚蓝档案", 1, BTreeMap::new()),
term("high", "Archive", "档案库", 10, BTreeMap::new()),
];
let evaluation = evaluate_glossary(&terms, "Blue Archive", &BTreeMap::new());
assert_eq!(evaluation.constraints.len(), 1);
assert_eq!(evaluation.constraints[0].term_id, "high");
assert!(evaluation
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Overridden));
}
#[test]
fn empty_alias_is_rejected() {
let mut draft = GlossaryTermDraft {
term_id: "term".to_string(),
definition: GlossaryTermSnapshot {
source_term: "Sensei".to_string(),
aliases: vec![" ".to_string()],
recommended_translation: "老师".to_string(),
allowed_translations: Vec::new(),
source_language: None,
target_language: None,
category: None,
priority: 0,
scope: BTreeMap::new(),
},
review_status: GlossaryReviewStatus::Draft,
source: GlossarySourceRecord {
source_kind: GlossarySourceKind::Manual,
source_ref: None,
source_author: None,
source_note: None,
observed_unix_seconds: 1,
},
};
assert!(validate_glossary_draft(&draft).is_err());
draft.definition.aliases.clear();
assert!(validate_glossary_draft(&draft).is_ok());
}
}
+7
View File
@@ -2,12 +2,19 @@
pub mod game_client;
pub mod game_version;
pub mod glossary;
pub mod resource;
pub mod translation;
pub mod translation_memory;
pub use game_client::{ClientStatus, GameClient, GameRegion};
pub use game_version::{GameVersion, UnityVersion};
pub use glossary::{
evaluate_glossary, validate_glossary_draft, GlossaryConstraint, GlossaryDiagnostic,
GlossaryDiagnosticKind, GlossaryEvaluation, GlossaryHistoryRecord, GlossaryOverride,
GlossaryQaReport, GlossaryQaStatus, GlossaryReviewStatus, GlossarySourceKind,
GlossarySourceRecord, GlossarySummary, GlossaryTerm, GlossaryTermDraft, GlossaryTermSnapshot,
};
pub use resource::{
crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType,
};
@@ -0,0 +1,15 @@
//! Glossary repository boundary.
use crate::domain::{GlossaryEvaluation, TranslationMemoryContext};
use async_trait::async_trait;
/// Read-only matching boundary consumed by translation workers.
#[async_trait]
pub trait GlossaryRepository: Send + Sync {
/// Evaluates approved terms against one source TextUnit.
async fn evaluate(
&self,
source_text: &str,
context: &TranslationMemoryContext,
) -> crate::Result<GlossaryEvaluation>;
}
+2
View File
@@ -3,11 +3,13 @@
//! 定义所有数据访问接口
pub mod cas_repository;
pub mod glossary_repository;
pub mod resource_repository;
pub mod translation_memory_repository;
pub mod translation_repository;
pub use cas_repository::CasRepository;
pub use glossary_repository::GlossaryRepository;
pub use resource_repository::ResourceRepository;
pub use translation_memory_repository::TranslationMemoryRepository;
pub use translation_repository::TranslationRepository;
+7 -6
View File
@@ -196,13 +196,14 @@ pub struct ParserRegistry {
### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担)
当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、
lease/retry、结果落库项目级 Translation Memory V1。TM 位于独立 SQLite,按 raw
source + 完整 context 做 trusted exact reusecandidate 必须显式 confirmGlossary、
模糊匹配和完整 Provider 体系仍属后续缺口。
lease/retry、结果落库项目级 Translation Memory V1 和独立 Glossary V1。TM 位于独立
SQLite,按 raw source + 完整 context 做 trusted exact reusecandidate 必须显式 confirm
Glossary 只有 approved term 进入 provider/TM 自动流程,并在结果上执行确定性 QA;模糊
匹配和完整 Provider 体系仍属后续缺口。
**架构**
```
Text Extractor → TM exact query → AI Provider → Glossary (后续) → Output
Text Extractor → Glossary constraints + TM exact query → AI Provider → Glossary QA → Output
↓ ↓
PostgreSQL 审核队列
```
@@ -295,7 +296,7 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
### 7. Web 后台 (Vue 3,目标设计)
当前只有 `bat-api` 内嵌 dashboard MVP;登录、角色、术语管理和完整协作审核仍未实现。
当前只有 `bat-api` 内嵌 dashboard MVPRust `bat` 的 Glossary V1 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。
**技术栈**
- Vue 3 + Composition API
@@ -308,7 +309,7 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
**模块**
- Dashboard(统计概览)
- 翻译审核(Translation Review
- 术语管理(Glossary Manager
- Web 术语管理(Glossary Manager
- 资源浏览(Asset Browser
- 用户管理(User Management
@@ -36,7 +36,7 @@
- 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由
`internal/backendrpc` 消费。
4. **完整游戏业务 API、完整 Web 协作后台、Glossary 和 Provider 扩展体系仍是后续目标**
4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标Rust `bat` 已持有 Glossary V1Web 术语协作视图仍待建设**
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
---
+1 -1
View File
@@ -89,7 +89,7 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
当前开发优先推进:
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
2. 基于 `translation.worker.run` 扩展 TM/Glossary通用 manifest Patch 构建。
2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并推进通用 manifest Patch 构建。
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
4.`docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
+16
View File
@@ -161,6 +161,22 @@ bat i18n worker run \
`--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须
显式指定 `--interval`
Glossary V1 是 Rust `bat` 持有的独立项目级 SQLite 资产,默认位于
`<output>/glossary.sqlite`;也可以用 `--glossary-path`
`BAT_GLOSSARY_PATH``[translation.worker].glossary_path` 指定。worker 只把
`approved` term 转成 provider-neutral constraints,并在 TM 复用、provider 返回
和人工工作台/任务回写时执行相同的确定性 QA。冲突或不符合推荐/允许译法的结果会
阻止自动完成;必须提交带 reviewer、reason 和 provenance 的显式 override。
常用 Glossary 操作:
```bash
bat i18n glossary summary --output /tmp/bat-resources
bat i18n glossary query --glossary-source-text 'Sensei' --glossary-review-status approved
bat i18n glossary diagnose --glossary-source-text 'Sensei' \
--glossary-context-json '{"destination":"Table.bytes"}'
```
外部 provider 或人工流程也可以用 `i18n task update` 回写当前 release 的任务状态:
```bash
+2 -2
View File
@@ -8,7 +8,7 @@ BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式:
本地资源状态使用文件和 SQLite。
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。
3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root`
4. **可选数据库开发环境**PostgreSQL/Redis 只服务于未来的 Go 服务层、Glossary 和完整
4. **可选数据库开发环境**PostgreSQL/Redis 只服务于未来的 Go 服务层、完整 Web 协作后台和
Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory V1
使用 `<output>/translation-memory.sqlite`
5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。
@@ -39,7 +39,7 @@ docker compose -f deployments/docker-compose.dev.yml --profile local-db up -d
## 模式 2:可选数据库开发环境(目标能力)
PostgreSQL 和 Redis 不是当前 `bat` / `bat-api` 的生产运行依赖。本模式只用于未来
服务层、Glossary 或 Provider 扩展的开发验证,不能作为当前
服务层、Web 协作视图或 Provider 扩展的开发验证,不能作为当前
资源同步或资源分发的部署前置条件。
### 远程开发连接
+35 -3
View File
@@ -164,6 +164,11 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
provenance。默认路径为 `<output>/translation-memory.sqlite`,可由
`BAT_TRANSLATION_MEMORY_PATH``[translation.worker].translation_memory_path` 或 CLI
覆盖。
- `glossary.sqlite`:跨 release 的项目级 Glossary,不位于 `versions/<id>`,也不与
`translation-tasks.sqlite` 或 TM 共用;记录 term、alias、推荐/允许译法、scope、
priority、review 状态、source provenance 和完整 source/review history。默认路径为
`<output>/glossary.sqlite`,可由 `BAT_GLOSSARY_PATH``[translation.worker].glossary_path`
或 CLI 覆盖。
删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
@@ -218,6 +223,14 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
| `translation.memory.summary` | 已实现 | 可选 `{ "translation_memory_path": "..." }` | 返回 TM schema 版本、总记录数及 candidate/trusted/rejected/superseded 状态计数。 |
| `translation.memory.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenance。 |
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | 显式确认一条 candidate 为 trustedworker 之后才可自动复用。 |
| `translation.glossary.summary` | 已实现 | 可选 `{ "glossary_path": "..." }` | 返回 Glossary schema 版本和 draft/approved/deprecated/rejected 计数;缺库只返回 `available=false`,不会创建空库。 |
| `translation.glossary.query` | 已实现 | `{ "source_text": "...", "category": "...", "review_status": "approved", "limit": 100 }` | 查询 term、alias、scope、source provenance 和完整 source/review history。 |
| `translation.glossary.diagnose` | 已实现 | `{ "source_text": "...", "context": {...} }` | 只对 approved term 生成 provider-neutral constraints,并返回冲突/覆盖诊断和 blocked 决策。 |
| `translation.glossary.add` | 已实现 | Glossary term draft,包含 `term_id``source_term``recommended_translation``source` 等 | Rust 创建 draft/import term 并记录 source history。 |
| `translation.glossary.update` | 已实现 | term draft + `reviewer`,可选 `reason` | Rust 替换 term definition,并记录 source/review history。 |
| `translation.glossary.approve` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 将 term 明确置为 approved;只有 approved term 进入 worker/TM 自动流程。 |
| `translation.glossary.deprecate` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 保留历史但停止自动应用。 |
| `translation.glossary.delete` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 显式删除当前 term;需要 reviewer/reasonRust 另保留删除审计快照,返回删除前快照。 |
TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted`
context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会
@@ -273,7 +286,9 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在
`status=completed` 时额外提交 `provider``provider_run_id`
`translation_results[]`,每个结果必须包含 `unit_id``source_text`
`translated_text`Rust 会用当前 `official-textunit-index.json` 校验 unit
`translated_text`结果也可以提交完整的 `glossary_override``reviewer`
`reason``provenance``confirmed_unix_seconds`),用于人工确认 Glossary
blocking deviation。Rust 会用当前 `official-textunit-index.json` 校验 unit、
source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程
消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
@@ -299,6 +314,7 @@ provider worker 参数:
| `max_tasks` | uint/null | `null` | 本轮最多 claim 的任务数,设置时必须大于 0。 |
| `worker_id` | string | `bat-rpc-worker` | lease 诊断用 worker ID 前缀。 |
| `translation_memory_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 TM 数据库路径;未指定时使用 worker 配置或 `<output>/translation-memory.sqlite`。 |
| `glossary_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 Glossary 数据库路径;未指定时使用 worker 配置或 `<output>/glossary.sqlite`。存在 Glossary 但无法打开时 worker fail-closed,不自动绕过 QA。 |
数字字段必须是 JSON number;字符串数字、负数和越界值会返回
`BAT-ERR-700002``mock` provider 在没有 fixture 时把 source text 写成可诊断的
@@ -306,6 +322,12 @@ mock 译文;`crowdin` provider 从 `CROWDIN_PROJECT_ID`、`CROWDIN_LANGUAGE_ID
`CROWDIN_API_TOKEN` 读取配置,可选 `CROWDIN_API_BASE_URL``BAT_CURL`
token 不会进入报告、任务记录或调试输出。
Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 trusted TM
复用前、provider 返回后、人工 `translation.task.update` 和 workbench publish 前执行
同一套确定性 QA;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法
产生 warningblocking deviation 必须在对应结果中提交 `glossary_override`,并包含
`reviewer``reason``provenance` 和确认时间。系统不会在译文生成后做静默字符串替换。
### localized
| 方法 | 状态 | params | data |
@@ -449,6 +471,11 @@ CLI 对应关系:
| `bat i18n proofread` | `translation.proofread` |
| `bat i18n memory summary` / `bat i18n memory query` | `translation.memory.summary` / `translation.memory.query` |
| `bat i18n memory confirm` | `translation.memory.confirm` |
| `bat i18n glossary summary` / `bat i18n glossary query` | `translation.glossary.summary` / `translation.glossary.query` |
| `bat i18n glossary diagnose` | `translation.glossary.diagnose` |
| `bat i18n glossary add/update` | `translation.glossary.add` / `translation.glossary.update` |
| `bat i18n glossary approve/deprecate` | `translation.glossary.approve` / `translation.glossary.deprecate` |
| `bat i18n glossary delete` | `translation.glossary.delete` |
| `bat localized-status` | `localized.status` |
| `bat resource-index` | `resource.index` |
@@ -481,7 +508,10 @@ CLI 对应关系:
`localized.status``localized.publish``localized.rollback`
`translation.tasks``translation.handoff``translation.task.update`
`translation.worker.run``translation.proofread``translation.memory.summary`
`translation.memory.query``translation.memory.confirm`
`translation.memory.query``translation.memory.confirm``translation.glossary.summary`
`translation.glossary.query``translation.glossary.diagnose``translation.glossary.add`
`translation.glossary.update``translation.glossary.approve``translation.glossary.deprecate`
`translation.glossary.delete`
`task.*` 和三个 `unityfs.patch_*` 方法。
- `resource.index``patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
本契约的参数和响应定义。
@@ -498,6 +528,7 @@ CLI 对应关系:
| `ParseBackend` | `parse.status``parse.text_units``parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 |
| `TranslationBackend` | `translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 |
| `TranslationMemoryBackend` | `translation.memory.summary``translation.memory.query``translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认;Go 只转发,不持有 TM 状态 |
| `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核/删除 mutationGo 只转发,不持有 Glossary 状态 |
| `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
`daemon.stop``daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
@@ -507,7 +538,8 @@ Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
`catalog.status` available/unavailable、`resource.manifest` page0、对应
`official-sync-snapshot.json` 以及 Translation Memory query/缺库 mirror
`official-sync-snapshot.json`Translation Memory query/缺库 mirror 和 Glossary
query/source-history mirror。
这些 fixture/mirror 由 Rust 输出形状归一化而来,只用于
schema / mirror 回归;live daemon socket 和完整 fixture release 切换由
`make bat-api-local-live-smoke` 在同机 `/tmp` 隔离环境中验证。该 smoke 不替代
@@ -50,11 +50,13 @@
catalog-status.unavailable.raw.json
resource-manifest.page0.raw.json
official-sync-snapshot.raw.json
glossary-query.raw.json
normalized/
catalog-status.available.json
catalog-status.unavailable.json
resource-manifest.page0.json
official-sync-snapshot.json
glossary-query.json
notes.md
```
@@ -72,6 +74,8 @@ Rust 窗口请基于当前真实代码生成或导出以下 JSON:
2. `catalog.status` available=false 响应。
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
4. 对应 release 的 `official-sync-snapshot.json`
5. Rust Glossary V1 的 `translation.glossary.query` 响应,至少包含 alias、approved
review、source provenance 和 created/approved history。
输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
+4 -4
View File
@@ -113,11 +113,11 @@ rollback`localized.status` 能校验当前官方 release 与 patch manifest
### G-012Translation Memory V1 已实现,扩展能力仍缺失
Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance,区分 candidate/trusted,只有显式 confirm 才能建立 trusted 记录;worker 只自动复用 trusted 的 raw source + 完整 context exact match。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go 不持有 TM 状态。仍缺少模糊匹配、Glossary 联动和更丰富的导入导出历史能力。
Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance,区分 candidate/trusted,只有显式 confirm 才能建立 trusted 记录;worker 只自动复用 trusted 的 raw source + 完整 context exact match,并在复用前执行已批准 Glossary 的确定性 QA。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go 不持有 TM 状态。仍缺少模糊匹配和更丰富的导入导出历史能力。
### G-013Glossary 未实现
### G-013Glossary V1 已实现,协作视图仍缺失
需要支持术语优先级、别名、分类、冲突检测和审核
Rust `bat` 已提供独立项目级 `glossary.sqlite`term/alias/recommended/allowed/category/priority、全局与 TextUnit scope、source history、approved review、冲突诊断、provider-neutral constraints 和确定性 QA 均由 Rust 持有。trusted TM 复用会先经过 Glossary QAprovider、TM、人工 task/workbench 结果都记录 QAblocking deviation 必须显式提交 reviewer/reason/provenance。`translation.glossary.*` 已通过 `bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图和更丰富的导入/搜索能力
### G-014:完整 Provider 扩展体系未实现
@@ -145,7 +145,7 @@ Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整
1. 继续 G-005:真实 AssetBundle 样本、复杂字段解析和发布级重打包。
2. 继续 G-006/G-011D:通用 manifest Patch 和双 release 查询/清理策略。
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM 扩展、Glossary 和 Provider
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider
扩展体系。
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
5. 最后推进完整 Web 协作后台和完整游戏业务 API。
+2 -2
View File
@@ -95,8 +95,8 @@
| 组件 | 路径 | 状态 | 说明 |
|---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM admin forwarding + 内嵌 dashboard + `.env` |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
| 空骨架 | `api/``pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
+378 -3
View File
@@ -13,8 +13,9 @@ use bat_infrastructure::{
localized_patch_operations, open_append_file, read_download_manifest_at, read_file_no_symlink,
read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at,
read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state,
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation,
validate_output_root, validate_runtime_state_dir, validate_translation_workbench,
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
set_translation_checked_with_glossary_path, unset_translation, validate_output_root,
validate_runtime_state_dir, validate_translation_workbench_with_glossary_path,
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
@@ -54,6 +55,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[path = "config_file.rs"]
mod config_file;
#[path = "glossary_query.rs"]
mod glossary_query;
#[path = "patch_commands.rs"]
mod patch_commands;
#[path = "readonly_query.rs"]
@@ -70,6 +73,11 @@ mod terminal_output;
mod translation_query;
#[path = "workflow_commands.rs"]
mod workflow_commands;
use glossary_query::{
glossary_delete_rpc_report, glossary_diagnose_rpc_report, glossary_mutation_rpc_report,
glossary_query_rpc_report, glossary_review_rpc_report, glossary_rpc_envelope,
glossary_summary_rpc_report, run_glossary_command,
};
use patch_commands::{
is_write_patch_command, run_write_patch_command, validate_write_patch_options,
};
@@ -229,6 +237,17 @@ fn run() -> anyhow::Result<i32> {
run_translation_memory_command(&options)?;
Ok(0)
}
CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDelete
| CliCommand::GlossaryDiagnose => {
run_glossary_command(&options)?;
Ok(0)
}
CliCommand::TranslationWorker => {
run_repeated_workflow(&options, "translation-worker", run_translation_worker)?;
Ok(0)
@@ -385,6 +404,8 @@ struct CliOptions {
translation_provider: Option<String>,
translation_fixture: Option<PathBuf>,
translation_memory_path: Option<PathBuf>,
glossary_path: Option<PathBuf>,
glossary_path_option_explicit: bool,
translation_memory_option_explicit: bool,
translation_memory_command_option_explicit: bool,
translation_memory_source_text: Option<String>,
@@ -392,6 +413,26 @@ struct CliOptions {
translation_memory_record_id: Option<String>,
translation_memory_reviewer: Option<String>,
translation_memory_reason: Option<String>,
glossary_term_id: Option<String>,
glossary_source_term: Option<String>,
glossary_aliases_json: Option<String>,
glossary_recommended_translation: Option<String>,
glossary_allowed_translations_json: Option<String>,
glossary_source_language: Option<String>,
glossary_target_language: Option<String>,
glossary_category: Option<String>,
glossary_priority: i32,
glossary_scope_json: Option<String>,
glossary_source_kind: Option<String>,
glossary_source_ref: Option<String>,
glossary_source_author: Option<String>,
glossary_source_note: Option<String>,
glossary_reviewer: Option<String>,
glossary_reason: Option<String>,
glossary_override_provenance: Option<String>,
glossary_source_text: Option<String>,
glossary_context_json: Option<String>,
glossary_review_status: Option<String>,
worker_concurrency: usize,
worker_max_attempts: u32,
worker_lease_seconds: u64,
@@ -488,6 +529,8 @@ impl Default for CliOptions {
translation_provider: None,
translation_fixture: None,
translation_memory_path: None,
glossary_path: None,
glossary_path_option_explicit: false,
translation_memory_option_explicit: false,
translation_memory_command_option_explicit: false,
translation_memory_source_text: None,
@@ -495,6 +538,26 @@ impl Default for CliOptions {
translation_memory_record_id: None,
translation_memory_reviewer: None,
translation_memory_reason: None,
glossary_term_id: None,
glossary_source_term: None,
glossary_aliases_json: None,
glossary_recommended_translation: None,
glossary_allowed_translations_json: None,
glossary_source_language: None,
glossary_target_language: None,
glossary_category: None,
glossary_priority: 0,
glossary_scope_json: None,
glossary_source_kind: None,
glossary_source_ref: None,
glossary_source_author: None,
glossary_source_note: None,
glossary_reviewer: None,
glossary_reason: None,
glossary_override_provenance: None,
glossary_source_text: None,
glossary_context_json: None,
glossary_review_status: None,
worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY,
worker_max_attempts: DEFAULT_TRANSLATION_MAX_ATTEMPTS,
worker_lease_seconds: DEFAULT_TRANSLATION_LEASE_SECONDS,
@@ -590,6 +653,14 @@ enum CliCommand {
TranslationMemorySummary,
TranslationMemoryQuery,
TranslationMemoryConfirm,
GlossarySummary,
GlossaryQuery,
GlossaryAdd,
GlossaryUpdate,
GlossaryApprove,
GlossaryDeprecate,
GlossaryDelete,
GlossaryDiagnose,
Repack,
PublishLocalized,
LocalizedRollback,
@@ -1107,6 +1178,14 @@ const RPC_METHOD_TRANSLATION_WORKER_RUN: &str = "translation.worker.run";
const RPC_METHOD_TRANSLATION_MEMORY_SUMMARY: &str = "translation.memory.summary";
const RPC_METHOD_TRANSLATION_MEMORY_QUERY: &str = "translation.memory.query";
const RPC_METHOD_TRANSLATION_MEMORY_CONFIRM: &str = "translation.memory.confirm";
const RPC_METHOD_GLOSSARY_SUMMARY: &str = "translation.glossary.summary";
const RPC_METHOD_GLOSSARY_QUERY: &str = "translation.glossary.query";
const RPC_METHOD_GLOSSARY_ADD: &str = "translation.glossary.add";
const RPC_METHOD_GLOSSARY_UPDATE: &str = "translation.glossary.update";
const RPC_METHOD_GLOSSARY_APPROVE: &str = "translation.glossary.approve";
const RPC_METHOD_GLOSSARY_DEPRECATE: &str = "translation.glossary.deprecate";
const RPC_METHOD_GLOSSARY_DELETE: &str = "translation.glossary.delete";
const RPC_METHOD_GLOSSARY_DIAGNOSE: &str = "translation.glossary.diagnose";
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
@@ -2105,7 +2184,11 @@ fn dispatch_rpc_method(
RPC_METHOD_TRANSLATION_TASK_UPDATE => rpc_envelope_from_result(
request_id,
"translation.task.update",
update_translation_task_status_report(state_dir, request.params.as_ref()),
update_translation_task_status_report(
state_dir,
request.params.as_ref(),
tasks.translation_worker_config.glossary_path.as_deref(),
),
),
RPC_METHOD_TRANSLATION_PROOFREAD => {
let _sync_guard = tasks
@@ -2155,6 +2238,76 @@ fn dispatch_rpc_method(
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_SUMMARY => glossary_rpc_envelope(
request_id,
glossary_summary_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_QUERY => glossary_rpc_envelope(
request_id,
glossary_query_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_DIAGNOSE => glossary_rpc_envelope(
request_id,
glossary_diagnose_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_ADD => glossary_rpc_envelope(
request_id,
glossary_mutation_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
false,
),
),
RPC_METHOD_GLOSSARY_UPDATE => glossary_rpc_envelope(
request_id,
glossary_mutation_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
true,
),
),
RPC_METHOD_GLOSSARY_APPROVE | RPC_METHOD_GLOSSARY_DEPRECATE => glossary_rpc_envelope(
request_id,
glossary_review_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
if request.method == RPC_METHOD_GLOSSARY_APPROVE {
bat_core::domain::GlossaryReviewStatus::Approved
} else {
bat_core::domain::GlossaryReviewStatus::Deprecated
},
),
),
RPC_METHOD_GLOSSARY_DELETE => glossary_rpc_envelope(
request_id,
glossary_delete_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_TRANSLATION_WORKER_RUN => {
let config = match rpc_translation_worker_config_with_defaults(
request.params.as_ref(),
@@ -3504,6 +3657,13 @@ fn rpc_translation_worker_config_with_defaults(
)?
.map(PathBuf::from)
.or_else(|| defaults.translation_memory_path.clone()),
glossary_path: rpc_translation_worker_string_param(
params,
&["glossary_path", "translation_glossary_path"],
"glossary_path",
)?
.map(PathBuf::from)
.or_else(|| defaults.glossary_path.clone()),
};
config.validate().map_err(|error| {
ApiError::new(
@@ -6109,6 +6269,10 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
args.push("--translation-memory-path".to_string());
args.push(path.to_string_lossy().to_string());
}
if let Some(path) = options.glossary_path.as_ref() {
args.push("--glossary-path".to_string());
args.push(path.to_string_lossy().to_string());
}
args.push("--worker-concurrency".to_string());
args.push(options.worker_concurrency.to_string());
args.push("--worker-max-attempts".to_string());
@@ -6420,6 +6584,9 @@ fn apply_bat_env_overrides(
{
options.translation_memory_path = Some(PathBuf::from(v));
}
if let Some(v) = value("BAT_GLOSSARY_PATH") {
options.glossary_path = Some(PathBuf::from(v));
}
if let Some(v) = value("BAT_TRANSLATION_CONCURRENCY") {
options.worker_concurrency =
parse_translation_worker_concurrency(&v, "环境变量 BAT_TRANSLATION_CONCURRENCY")?;
@@ -6702,6 +6869,10 @@ fn parse_args_with_env(
options.translation_memory_option_explicit = true;
options.translation_worker_option_explicit = true;
}
"--glossary-path" | "--translation-glossary-path" => {
options.glossary_path = Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.glossary_path_option_explicit = true;
}
"--tm-source-text" => {
options.translation_memory_source_text = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
@@ -6723,6 +6894,70 @@ fn parse_args_with_env(
options.translation_memory_reason = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
}
"--glossary-term-id" => {
options.glossary_term_id = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-term" => {
options.glossary_source_term = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-aliases-json" => {
options.glossary_aliases_json = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-recommended-translation" => {
options.glossary_recommended_translation =
Some(next_option_value(&mut args, &flag)?);
}
"--glossary-allowed-translations-json" => {
options.glossary_allowed_translations_json =
Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-language" => {
options.glossary_source_language = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-target-language" => {
options.glossary_target_language = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-category" => {
options.glossary_category = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-priority" => {
options.glossary_priority = next_option_value(&mut args, &flag)?
.parse()
.map_err(|error| anyhow::anyhow!("--glossary-priority 无效:{error}"))?;
}
"--glossary-scope-json" => {
options.glossary_scope_json = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-kind" => {
options.glossary_source_kind = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-ref" => {
options.glossary_source_ref = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-author" => {
options.glossary_source_author = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-note" => {
options.glossary_source_note = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-reviewer" => {
options.glossary_reviewer = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-reason" => {
options.glossary_reason = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-provenance" | "--glossary-override-provenance" => {
options.glossary_override_provenance = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-text" => {
options.glossary_source_text = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-context-json" => {
options.glossary_context_json = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-review-status" => {
options.glossary_review_status = Some(next_option_value(&mut args, &flag)?);
}
"--worker-concurrency" | "--translation-concurrency" => {
options.worker_concurrency = parse_translation_worker_concurrency(
&next_option_value(&mut args, &flag)?,
@@ -7240,6 +7475,31 @@ fn parse_args_with_env(
"翻译 worker 参数只适用于 i18n worker run 或 daemon restart/reload"
));
}
if options.glossary_path_option_explicit
&& !options.daemon_child
&& !matches!(
options.command,
CliCommand::TranslationWorker
| CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDelete
| CliCommand::GlossaryDiagnose
| CliCommand::TranslationSet
| CliCommand::TranslationValidate
| CliCommand::TranslationTaskUpdate
| CliCommand::PublishLocalized
| CliCommand::Restart
| CliCommand::Reload
)
{
return Err(anyhow::anyhow!(
"Glossary 路径参数只适用于 Glossary、翻译 worker、工作台/任务发布或 daemon restart/reload"
));
}
if options.translation_memory_option_explicit
&& !matches!(
options.command,
@@ -7247,6 +7507,13 @@ fn parse_args_with_env(
| CliCommand::TranslationMemorySummary
| CliCommand::TranslationMemoryQuery
| CliCommand::TranslationMemoryConfirm
| CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose
| CliCommand::Restart
| CliCommand::Reload
)
@@ -7369,6 +7636,85 @@ fn parse_args_with_env(
options.progress = false;
options.banner = false;
}
CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDelete
| CliCommand::GlossaryDiagnose => {
if options.watch
|| options.daemon
|| options.daemon_child
|| options.config.force
|| options.config.dry_run
|| options.run_count.is_some()
|| options.sync_option_explicit
|| options.output_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config)
{
return Err(anyhow::anyhow!(
"i18n glossary 命令只接受 --state-dir、Glossary 参数和 --json/--human"
));
}
match options.command {
CliCommand::GlossarySummary => {}
CliCommand::GlossaryQuery => {
if options.glossary_term_id.is_some()
|| options.glossary_recommended_translation.is_some()
|| options.glossary_reviewer.is_some()
{
return Err(anyhow::anyhow!("Glossary query 不接受 mutation 参数"));
}
}
CliCommand::GlossaryDiagnose => {
if options.glossary_source_text.is_none() {
return Err(anyhow::anyhow!(
"Glossary diagnose 必须指定 --glossary-source-text"
));
}
}
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
if options.glossary_term_id.is_none()
|| options.glossary_source_term.is_none()
|| options.glossary_recommended_translation.is_none()
{
return Err(anyhow::anyhow!(
"Glossary add/update 必须指定 term-id、source-term 和 recommended-translation"
));
}
if options.command == CliCommand::GlossaryUpdate
&& options.glossary_reviewer.is_none()
{
return Err(anyhow::anyhow!(
"Glossary update 必须指定 --glossary-reviewer"
));
}
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
if options.glossary_term_id.is_none() || options.glossary_reviewer.is_none() {
return Err(anyhow::anyhow!(
"Glossary review 必须指定 --glossary-term-id 和 --glossary-reviewer"
));
}
}
CliCommand::GlossaryDelete => {
if options.glossary_term_id.is_none()
|| options.glossary_reviewer.is_none()
|| options.glossary_reason.is_none()
{
return Err(anyhow::anyhow!(
"Glossary delete 必须指定 --glossary-term-id、--glossary-reviewer 和 --glossary-reason"
));
}
}
_ => unreachable!(),
}
options.progress = false;
options.banner = false;
}
CliCommand::PatchApply
| CliCommand::UnityFsPatchTextAsset
| CliCommand::UnityFsPatchStringField
@@ -7916,6 +8262,9 @@ fn parse_translation_command(
if action == "memory" || action == "tm" {
return parse_translation_memory_command(args, options);
}
if action == "glossary" || action == "terms" {
return parse_translation_glossary_command(args, options);
}
let command = match action.as_str() {
"run" => CliCommand::Translate,
"export" => CliCommand::Translate,
@@ -7956,6 +8305,31 @@ fn parse_translation_memory_command(
Ok(())
}
fn parse_translation_glossary_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
) -> anyhow::Result<()> {
let action = next_option_value(args, "translation glossary")?;
let command = match action.as_str() {
"summary" | "status" => CliCommand::GlossarySummary,
"query" | "find" => CliCommand::GlossaryQuery,
"add" | "create" => CliCommand::GlossaryAdd,
"update" | "edit" => CliCommand::GlossaryUpdate,
"approve" | "trust" => CliCommand::GlossaryApprove,
"deprecate" | "retire" => CliCommand::GlossaryDeprecate,
"delete" | "remove" => CliCommand::GlossaryDelete,
"diagnose" | "check" => CliCommand::GlossaryDiagnose,
other => {
return Err(anyhow::anyhow!(
"未知 translation glossary 二级命令:{other}"
))
}
};
ensure_command_not_set(options.command, &format!("translation glossary {action}"))?;
options.command = command;
Ok(())
}
fn parse_translation_worker_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
@@ -8086,6 +8460,7 @@ fn translation_worker_config_from_options(
.clone()
.unwrap_or_else(|| default_worker_id.to_string()),
translation_memory_path: options.translation_memory_path.clone(),
glossary_path: options.glossary_path.clone(),
};
config.validate()?;
Ok(config)
+192
View File
@@ -366,6 +366,91 @@ fn translation_memory_subcommands_reject_irrelevant_options() {
assert!(error.to_string().contains("query 参数"));
}
#[test]
fn glossary_commands_parse_and_validate() {
let query = parse(&[
"bat",
"i18n",
"glossary",
"query",
"--glossary-source-text",
"Sensei",
"--glossary-review-status",
"approved",
"--limit",
"5",
])
.unwrap();
assert_eq!(query.command, CliCommand::GlossaryQuery);
assert_eq!(query.glossary_source_text.as_deref(), Some("Sensei"));
assert_eq!(query.glossary_review_status.as_deref(), Some("approved"));
assert_eq!(query.query_limit, 5);
let add = parse(&[
"bat",
"i18n",
"glossary",
"add",
"--glossary-term-id",
"term-sensei",
"--glossary-source-term",
"Sensei",
"--glossary-recommended-translation",
"老师",
"--glossary-source-kind",
"manual",
"--glossary-scope-json",
r#"{"destination":"story.bundle"}"#,
"--glossary-path",
"/tmp/project-glossary.sqlite",
])
.unwrap();
assert_eq!(add.command, CliCommand::GlossaryAdd);
assert_eq!(add.glossary_term_id.as_deref(), Some("term-sensei"));
assert_eq!(add.glossary_priority, 0);
assert_eq!(
add.glossary_path,
Some(PathBuf::from("/tmp/project-glossary.sqlite"))
);
let diagnose = parse(&[
"bat",
"translation",
"glossary",
"diagnose",
"--glossary-source-text",
"Sensei",
"--glossary-context-json",
r#"{"destination":"story.bundle"}"#,
])
.unwrap();
assert_eq!(diagnose.command, CliCommand::GlossaryDiagnose);
let delete = parse(&[
"bat",
"i18n",
"glossary",
"delete",
"--glossary-term-id",
"term-sensei",
"--glossary-reviewer",
"operator",
"--glossary-reason",
"duplicate",
])
.unwrap();
assert_eq!(delete.command, CliCommand::GlossaryDelete);
assert!(parse(&["bat", "i18n", "glossary", "diagnose"]).is_err());
assert!(parse(&[
"bat",
"i18n",
"glossary",
"query",
"--glossary-term-id",
"term-sensei",
])
.is_err());
}
#[test]
fn translation_worker_env_defaults_apply() {
let options = parse_with_env(
@@ -765,6 +850,8 @@ fn translation_workbench_commands_read_update_and_clear_entries() {
review_status: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
glossary_qa: None,
glossary_override: None,
}],
};
bat_infrastructure::write_translation_workbench(&path, &workbench).unwrap();
@@ -2679,6 +2766,111 @@ fn dispatch_translation_memory_summary_defaults_to_worker_config_path() {
);
}
#[test]
fn dispatch_glossary_summary_reports_missing_database_without_creating_it() {
let temp = tempfile::TempDir::new().unwrap();
let output_root = temp.path().join("output");
let state_dir = temp.path().join("state");
let (queue, _rx) = mpsc::channel::<TaskJob>();
let context = DaemonTaskContext {
registry: TaskRegistry::new(),
queue,
base_config: OfficialUpdateConfig {
output_root: output_root.clone(),
..Default::default()
},
translation_worker_config: TranslationWorkerConfig::default(),
sync_lock: Arc::new(Mutex::new(())),
restart_controller: test_restart_controller,
};
let envelope = dispatch_rpc_method(
&rpc_request("translation.glossary.summary", None),
&state_dir,
&new_daemon_control(),
&context,
"req-glossary-summary-1".to_string(),
);
let value = serde_json::to_value(envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["available"], false);
assert_eq!(value["data"]["reason"], "database_missing");
assert!(!output_root.join("glossary.sqlite").exists());
}
#[test]
fn dispatch_glossary_delete_removes_term_and_returns_snapshot() {
let temp = tempfile::TempDir::new().unwrap();
let output_root = temp.path().join("output");
let state_dir = temp.path().join("state");
let glossary_path = output_root.join("glossary.sqlite");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let repository = bat_infrastructure::SqliteGlossaryRepository::new(&glossary_path)
.await
.unwrap();
repository
.add(bat_core::domain::GlossaryTermDraft {
term_id: "term-sensei".to_string(),
definition: bat_core::domain::GlossaryTermSnapshot {
source_term: "Sensei".to_string(),
aliases: vec!["Teacher".to_string()],
recommended_translation: "老师".to_string(),
allowed_translations: Vec::new(),
source_language: None,
target_language: None,
category: Some("person".to_string()),
priority: 10,
scope: Default::default(),
},
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
source: bat_core::domain::GlossarySourceRecord {
source_kind: bat_core::domain::GlossarySourceKind::Manual,
source_ref: Some("test".to_string()),
source_author: Some("tester".to_string()),
source_note: None,
observed_unix_seconds: 100,
},
})
.await
.unwrap();
});
let context = test_task_context_with_config(OfficialUpdateConfig {
output_root: output_root.clone(),
..Default::default()
});
let envelope = dispatch_rpc_method(
&rpc_request(
"translation.glossary.delete",
Some(serde_json::json!({
"term_id": "term-sensei",
"reviewer": "reviewer",
"reason": "duplicate"
})),
),
&state_dir,
&new_daemon_control(),
&context,
"req-glossary-delete-1".to_string(),
);
let value = serde_json::to_value(envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["deleted"], true);
assert_eq!(value["data"]["term"]["term_id"], "term-sensei");
assert_eq!(value["data"]["term"]["source_term"], "Sensei");
runtime.block_on(async {
let repository = bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
.await
.unwrap();
assert!(repository.find("term-sensei").await.is_err());
});
}
#[test]
fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
let temp = tempfile::TempDir::new().unwrap();
@@ -67,6 +67,7 @@ download_concurrency = 8
provider = 'mock'
fixture = ''
translation_memory_path = ''
glossary_path = ''
concurrency = 8
max_attempts = 3
lease_seconds = 300
@@ -150,6 +151,7 @@ struct TranslationWorkerSection {
provider: Option<String>,
fixture: Option<PathBuf>,
translation_memory_path: Option<PathBuf>,
glossary_path: Option<PathBuf>,
concurrency: Option<usize>,
max_attempts: Option<u32>,
lease_seconds: Option<u64>,
@@ -367,6 +369,9 @@ impl BatConfigFile {
if let Some(value) = self.translation.worker.translation_memory_path.as_ref() {
options.translation_memory_path = Some(value.clone());
}
if let Some(value) = self.translation.worker.glossary_path.as_ref() {
options.glossary_path = Some(value.clone());
}
if let Some(value) = self.translation.worker.concurrency {
options.worker_concurrency = value;
}
@@ -591,6 +596,10 @@ impl BatConfigFile {
line_number,
)?;
}
(SectionPath::TranslationWorker, "glossary_path") => {
self.translation.worker.glossary_path =
parse_optional_path(value, "translation.worker.glossary_path", line_number)?;
}
(SectionPath::TranslationWorker, "concurrency") => {
self.translation.worker.concurrency = Some(parse_translation_worker_concurrency(
&parse_scalar_text(value, "translation.worker.concurrency", line_number)?,
@@ -0,0 +1,728 @@
use super::report_output::print_json_value;
use super::*;
use bat_core::domain::{
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossaryTermDraft,
GlossaryTermSnapshot, TranslationMemoryContext,
};
use bat_infrastructure::{SqliteGlossaryRepository, GLOSSARY_SCHEMA_VERSION};
use std::collections::BTreeMap;
pub(super) fn run_glossary_command(options: &CliOptions) -> anyhow::Result<()> {
let method = glossary_method(options.command)?;
if daemon_rpc_available(&options.state_dir)
&& options.resource_root.is_none()
&& !options.output_explicit
{
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = daemon_rpc_call(&options.state_dir, method, glossary_cli_params(options)?)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let path = glossary_cli_path(options)?;
let report =
match options.command {
CliCommand::GlossarySummary => build_glossary_summary_report(&path)?,
CliCommand::GlossaryQuery => build_glossary_query_report(
&path,
options.glossary_source_text.as_deref(),
options.glossary_category.as_deref(),
options.glossary_review_status.as_deref(),
options.query_limit,
)?,
CliCommand::GlossaryDiagnose => build_glossary_diagnose_report(
&path,
options.glossary_source_text.as_deref().unwrap_or_default(),
parse_glossary_context(options.glossary_context_json.as_deref())?,
)?,
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
let draft = glossary_term_draft(options)?;
let reviewer = options.glossary_reviewer.as_deref();
build_glossary_mutation_report(
&path,
&draft,
options.command == CliCommand::GlossaryUpdate,
reviewer,
options.glossary_reason.clone(),
)?
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary review 必须指定 --glossary-term-id")
})?;
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary review 必须指定 --glossary-reviewer")
})?;
let status = if options.command == CliCommand::GlossaryApprove {
GlossaryReviewStatus::Approved
} else {
GlossaryReviewStatus::Deprecated
};
build_glossary_review_report(
&path,
term_id,
status,
reviewer,
options.glossary_reason.clone(),
)?
}
CliCommand::GlossaryDelete => {
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary delete 必须指定 --glossary-term-id")
})?;
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary delete 必须指定 --glossary-reviewer")
})?;
let reason = options
.glossary_reason
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Glossary delete 必须指定 --glossary-reason"))?;
build_glossary_delete_report(&path, term_id, reviewer, reason)?
}
_ => unreachable!(),
};
print_json_value(options.output_format, &report)
}
fn glossary_method(command: CliCommand) -> anyhow::Result<&'static str> {
Ok(match command {
CliCommand::GlossarySummary => RPC_METHOD_GLOSSARY_SUMMARY,
CliCommand::GlossaryQuery => RPC_METHOD_GLOSSARY_QUERY,
CliCommand::GlossaryAdd => RPC_METHOD_GLOSSARY_ADD,
CliCommand::GlossaryUpdate => RPC_METHOD_GLOSSARY_UPDATE,
CliCommand::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE,
CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE,
CliCommand::GlossaryDelete => RPC_METHOD_GLOSSARY_DELETE,
CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE,
_ => return Err(anyhow::anyhow!("不是 Glossary 命令")),
})
}
fn glossary_cli_path(options: &CliOptions) -> anyhow::Result<std::path::PathBuf> {
if let Some(path) = options.glossary_path.as_ref() {
return lexical_absolute(path).map_err(anyhow::Error::msg);
}
let resource_root = options
.resource_root
.as_deref()
.map(lexical_absolute)
.transpose()
.map_err(anyhow::Error::msg)?
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
Ok(SqliteGlossaryRepository::repository_path(&resource_root))
}
fn glossary_cli_params(options: &CliOptions) -> anyhow::Result<Option<serde_json::Value>> {
let mut params = serde_json::Map::new();
if let Some(path) = options.glossary_path.as_ref() {
params.insert("glossary_path".to_string(), serde_json::json!(path));
}
match options.command {
CliCommand::GlossarySummary => {}
CliCommand::GlossaryQuery => {
if let Some(source_text) = options.glossary_source_text.as_deref() {
params.insert("source_text".to_string(), serde_json::json!(source_text));
}
if let Some(category) = options.glossary_category.as_deref() {
params.insert("category".to_string(), serde_json::json!(category));
}
if let Some(status) = options.glossary_review_status.as_deref() {
params.insert("review_status".to_string(), serde_json::json!(status));
}
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
}
CliCommand::GlossaryDiagnose => {
let source_text = options.glossary_source_text.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary diagnose 必须指定 --glossary-source-text")
})?;
params.insert("source_text".to_string(), serde_json::json!(source_text));
params.insert(
"context".to_string(),
serde_json::json!(parse_glossary_context(
options.glossary_context_json.as_deref()
)?),
);
}
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
let draft = glossary_term_draft(options)?;
params.extend(
serde_json::to_value(draft)?
.as_object()
.cloned()
.unwrap_or_default(),
);
if options.command == CliCommand::GlossaryUpdate {
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
if let Some(reason) = options.glossary_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
params.insert(
"term_id".to_string(),
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
);
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
if let Some(reason) = options.glossary_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
CliCommand::GlossaryDelete => {
params.insert(
"term_id".to_string(),
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
);
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
params.insert(
"reason".to_string(),
serde_json::json!(options.glossary_reason.as_deref().unwrap_or_default()),
);
}
_ => unreachable!(),
}
Ok(Some(serde_json::Value::Object(params)))
}
fn glossary_term_draft(options: &CliOptions) -> anyhow::Result<GlossaryTermDraft> {
let term_id = required_option(options.glossary_term_id.as_deref(), "--glossary-term-id")?;
let source_term = required_option(
options.glossary_source_term.as_deref(),
"--glossary-source-term",
)?;
let recommended_translation = required_option(
options.glossary_recommended_translation.as_deref(),
"--glossary-recommended-translation",
)?;
let aliases = parse_string_array(
options.glossary_aliases_json.as_deref(),
"--glossary-aliases-json",
)?;
let allowed_translations = parse_string_array(
options.glossary_allowed_translations_json.as_deref(),
"--glossary-allowed-translations-json",
)?;
let scope = parse_glossary_context(options.glossary_scope_json.as_deref())?;
let source_kind = options.glossary_source_kind.as_deref().unwrap_or("manual");
let source_kind = GlossarySourceKind::parse(source_kind)
.ok_or_else(|| anyhow::anyhow!("Glossary source kind 无效:{source_kind}"))?;
let review_status = options.glossary_review_status.as_deref().unwrap_or("draft");
let review_status = GlossaryReviewStatus::parse(review_status)
.ok_or_else(|| anyhow::anyhow!("Glossary review status 无效:{review_status}"))?;
let now = unix_seconds_now();
Ok(GlossaryTermDraft {
term_id,
definition: GlossaryTermSnapshot {
source_term,
aliases,
recommended_translation,
allowed_translations,
source_language: options.glossary_source_language.clone(),
target_language: options.glossary_target_language.clone(),
category: options.glossary_category.clone(),
priority: options.glossary_priority,
scope,
},
review_status,
source: GlossarySourceRecord {
source_kind,
source_ref: options.glossary_source_ref.clone(),
source_author: options.glossary_source_author.clone(),
source_note: options.glossary_source_note.clone(),
observed_unix_seconds: now,
},
})
}
fn required_option(value: Option<&str>, label: &str) -> anyhow::Result<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("Glossary 必须指定 {label}"))
}
fn parse_string_array(value: Option<&str>, label: &str) -> anyhow::Result<Vec<String>> {
let Some(value) = value else {
return Ok(Vec::new());
};
serde_json::from_str(value)
.map_err(|error| anyhow::anyhow!("{label} 必须是 JSON string array{error}"))
}
fn parse_glossary_context(value: Option<&str>) -> anyhow::Result<TranslationMemoryContext> {
let Some(value) = value else {
return Ok(BTreeMap::new());
};
serde_json::from_str(value)
.map_err(|error| anyhow::anyhow!("Glossary context 必须是 JSON object{error}"))
}
pub(super) fn build_glossary_summary_report(
path: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let summary = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.summary()
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"summary": summary,
}))
}
pub(super) fn build_glossary_query_report(
path: &std::path::Path,
source_text: Option<&str>,
category: Option<&str>,
review_status: Option<&str>,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let status = review_status
.map(|value| {
GlossaryReviewStatus::parse(value)
.ok_or_else(|| anyhow::anyhow!("Glossary review_status 无效"))
})
.transpose()?;
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"source_text": source_text,
"terms": [],
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let terms = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.query(source_text, category, status, limit)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"source_text": source_text,
"terms": terms,
}))
}
pub(super) fn build_glossary_diagnose_report(
path: &std::path::Path,
source_text: &str,
context: TranslationMemoryContext,
) -> anyhow::Result<serde_json::Value> {
if source_text.trim().is_empty() {
return Err(anyhow::anyhow!("Glossary diagnose 的 source_text 不能为空"));
}
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"source_text": source_text,
"context": context,
"evaluation": {
"constraints": [],
"diagnostics": [],
"blocked": false
},
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let evaluation = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.diagnose(source_text, &context)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"source_text": source_text,
"context": context,
"evaluation": evaluation,
}))
}
pub(super) fn build_glossary_mutation_report(
path: &std::path::Path,
draft: &GlossaryTermDraft,
update: bool,
reviewer: Option<&str>,
reason: Option<String>,
) -> anyhow::Result<serde_json::Value> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let term = runtime.block_on(async {
let repository = SqliteGlossaryRepository::new(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let result = if update {
repository
.update(
draft.clone(),
reviewer.ok_or_else(|| anyhow::anyhow!("Glossary update 需要 reviewer"))?,
reason,
)
.await
} else {
repository.add(draft.clone()).await
};
result.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"term": term,
}))
}
pub(super) fn build_glossary_review_report(
path: &std::path::Path,
term_id: &str,
status: GlossaryReviewStatus,
reviewer: &str,
reason: Option<String>,
) -> anyhow::Result<serde_json::Value> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let term = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.review(term_id, status, reviewer, reason)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"term": term,
}))
}
pub(super) fn build_glossary_delete_report(
path: &std::path::Path,
term_id: &str,
reviewer: &str,
reason: &str,
) -> anyhow::Result<serde_json::Value> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let term = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.delete(term_id, reviewer, reason)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"deleted": true,
"term": term,
}))
}
pub(super) fn glossary_rpc_envelope(
request_id: String,
result: Result<serde_json::Value, ApiError>,
) -> RpcEnvelope {
match result {
Ok(data) => rpc_envelope_ok(request_id, "ok", data),
Err(error) => rpc_envelope_error(request_id, error),
}
}
pub(super) fn glossary_summary_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
params,
RPC_METHOD_GLOSSARY_SUMMARY,
)?;
build_glossary_summary_report(&path)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_SUMMARY, error))
}
pub(super) fn glossary_query_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_QUERY)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
let category = glossary_string(&params, "category", RPC_METHOD_GLOSSARY_QUERY)?;
let review_status = glossary_string(&params, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
let limit = glossary_limit(&params, RPC_METHOD_GLOSSARY_QUERY)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_QUERY,
)?;
build_glossary_query_report(&path, source_text, category, review_status, limit)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_QUERY, error))
}
pub(super) fn glossary_diagnose_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
let context = glossary_context(&params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_DIAGNOSE,
)?;
build_glossary_diagnose_report(&path, source_text, context)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DIAGNOSE, error))
}
pub(super) fn glossary_mutation_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
update: bool,
) -> Result<serde_json::Value, ApiError> {
let method = if update {
RPC_METHOD_GLOSSARY_UPDATE
} else {
RPC_METHOD_GLOSSARY_ADD
};
let params = glossary_params(params, method)?;
let draft: GlossaryTermDraft =
serde_json::from_value(serde_json::Value::Object(params.clone())).map_err(|error| {
glossary_invalid(method, format!("Glossary term 参数无效:{error}"))
})?;
let reviewer = glossary_string(&params, "reviewer", method)?;
if update && reviewer.is_none() {
return Err(glossary_invalid(method, "update 缺少 reviewer"));
}
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_mutation_report(&path, &draft, update, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
pub(super) fn glossary_review_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
status: GlossaryReviewStatus,
) -> Result<serde_json::Value, ApiError> {
let method = if status == GlossaryReviewStatus::Approved {
RPC_METHOD_GLOSSARY_APPROVE
} else {
RPC_METHOD_GLOSSARY_DEPRECATE
};
let params = glossary_params(params, method)?;
let term_id = glossary_string(&params, "term_id", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
let reviewer = glossary_string(&params, "reviewer", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_review_report(&path, term_id, status, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
pub(super) fn glossary_delete_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DELETE)?;
let term_id = glossary_string(&params, "term_id", RPC_METHOD_GLOSSARY_DELETE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 term_id"))?;
let reviewer = glossary_string(&params, "reviewer", RPC_METHOD_GLOSSARY_DELETE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reviewer"))?;
let reason = glossary_string(&params, "reason", RPC_METHOD_GLOSSARY_DELETE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reason"))?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_DELETE,
)?;
build_glossary_delete_report(&path, term_id, reviewer, reason)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DELETE, error))
}
fn glossary_rpc_path(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<std::path::PathBuf, ApiError> {
if let Some(path) = params
.and_then(|value| value.get("glossary_path"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return lexical_absolute(Path::new(path))
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
}
if let Some(path) = default_path {
return lexical_absolute(path)
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
}
let (_, version_state) = read_daemon_resource_state(state_dir)
.map_err(|error| glossary_internal_error(method, error))?;
if let Some(record) = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
{
return Ok(SqliteGlossaryRepository::repository_path(
&record.resource_root,
));
}
Ok(SqliteGlossaryRepository::repository_path(output_root))
}
fn glossary_params(
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<serde_json::Map<String, serde_json::Value>, ApiError> {
match params {
None | Some(serde_json::Value::Null) => Ok(serde_json::Map::new()),
Some(serde_json::Value::Object(value)) => Ok(value.clone()),
Some(_) => Err(glossary_invalid(method, "params 必须是 JSON object")),
}
}
fn glossary_string<'a>(
params: &'a serde_json::Map<String, serde_json::Value>,
key: &str,
method: &'static str,
) -> Result<Option<&'a str>, ApiError> {
let Some(value) = params.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| glossary_invalid(method, format!("{key} 必须是非空字符串")))
.map(Some)
}
fn glossary_context(
params: &serde_json::Map<String, serde_json::Value>,
method: &'static str,
) -> Result<TranslationMemoryContext, ApiError> {
let Some(value) = params
.get("context")
.or_else(|| params.get("source_context"))
else {
return Ok(BTreeMap::new());
};
serde_json::from_value(value.clone()).map_err(|error| {
glossary_invalid(method, format!("context 必须是 JSON string map{error}"))
})
}
fn glossary_limit(
params: &serde_json::Map<String, serde_json::Value>,
method: &'static str,
) -> Result<usize, ApiError> {
let limit = params
.get("limit")
.and_then(serde_json::Value::as_u64)
.unwrap_or(100);
let limit = usize::try_from(limit)
.map_err(|error| glossary_invalid(method, format!("limit 无效:{error}")))?;
if !(1..=1000).contains(&limit) {
return Err(glossary_invalid(method, "limit 必须在 1..=1000 范围内"));
}
Ok(limit)
}
fn glossary_invalid(method: &'static str, message: impl Into<String>) -> ApiError {
ApiError::new(ErrorCode::RPC_INVALID_PARAMS, method, message.into())
}
fn glossary_internal_error(method: &'static str, error: anyhow::Error) -> ApiError {
ApiError::new(ErrorCode::INTERNAL, method, error.to_string())
}
@@ -115,9 +115,15 @@ impl HumanReport for bat_infrastructure::TranslationWorkerReport {
print_field("TM 可用", format_bool(self.translation_memory_available));
print_field("TM 命中 TextUnit", self.translation_memory_hit_count);
print_field("Provider TextUnit", self.provider_unit_count);
print_path_field("Glossary", &self.glossary_path);
print_field("Glossary 可用", format_bool(self.glossary_available));
print_field("Glossary blocking TextUnit", self.glossary_blocked_count);
for failure in &self.translation_memory_failures {
println!(" - TM: {failure}");
}
for failure in &self.glossary_failures {
println!(" - Glossary: {failure}");
}
for failure in &self.failures {
println!(
" - {} [{}] retryable={} {}",
@@ -345,6 +345,11 @@ Commands:
i18n handoff Query current translation handoff
i18n status Show localized release status for current official release
i18n task update Update one provider worker task status
i18n glossary summary/query Show project Glossary terms and review counts
i18n glossary add/update Add or replace one Glossary term definition
i18n glossary approve/deprecate Review one Glossary term
i18n glossary delete Remove one Glossary term with reviewer and reason
i18n glossary diagnose Run deterministic Glossary QA for one TextUnit source
i18n publish Publish a localized release from a workbench or worker results
i18n rollback Roll back the current localized release
i18n schedule Manage translation schedules
@@ -384,6 +389,7 @@ Examples:
{binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1
{binary} i18n proofread --json
{binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s
{binary} i18n glossary diagnose --glossary-source-text Sensei --json
{binary} i18n tasks --json
{binary} i18n handoff --json
{binary} i18n status --json
@@ -433,6 +439,15 @@ Sync:
--provider-run-id <ID> Provider run ID for i18n task update
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
--translation-fixture <PATH> Mock/provider fixture for i18n worker run
--glossary-path <PATH> Project Glossary SQLite path
--glossary-term-id <ID> Glossary term ID for add/update/review/delete
--glossary-source-term <TEXT> Source spelling for a Glossary term
--glossary-recommended-translation <TEXT> Recommended target translation
--glossary-source-text <TEXT> Source TextUnit text for Glossary query/diagnose
--glossary-context-json <JSON> TextUnit context for Glossary diagnose
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews/delete
--glossary-reason <TEXT> Reason for Glossary review/delete or override
--glossary-provenance <TEXT> Provenance for an explicit Glossary override
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
--worker-max-attempts <N> Maximum claims per translation task
--worker-lease-seconds <N> Lease seconds for one claimed task
@@ -1,6 +1,6 @@
use super::report_output::print_json_value;
use super::*;
use bat_core::domain::TranslationMemoryContext;
use bat_core::domain::{GlossaryOverride, TranslationMemoryContext};
use bat_core::repositories::TranslationMemoryRepository;
use std::collections::BTreeMap;
@@ -9,6 +9,8 @@ struct TranslationTaskResultUpdateParam {
unit_id: String,
source_text: String,
translated_text: String,
#[serde(default)]
glossary_override: Option<GlossaryOverride>,
}
pub(super) fn build_translation_tasks_report(
@@ -170,6 +172,7 @@ pub(super) fn build_translation_handoff_report(
pub(super) fn update_translation_task_status_report(
state_dir: &Path,
params: Option<&serde_json::Value>,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<serde_json::Value> {
let task_id = rpc_string_param(params, "task_id")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
@@ -229,6 +232,22 @@ pub(super) fn update_translation_task_status_report(
.find(task_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let glossary_path = configured_glossary_path
.map(PathBuf::from)
.unwrap_or_else(|| {
bat_infrastructure::SqliteGlossaryRepository::repository_path(
&current.resource_root,
)
});
let glossary = if std::fs::symlink_metadata(&glossary_path).is_ok() {
Some(
bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
.await
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))?,
)
} else {
None
};
let results = build_manual_translation_results(
&current_task,
index,
@@ -236,7 +255,9 @@ pub(super) fn update_translation_task_status_report(
&result_provider,
&result_provider_run_id,
result_timestamp,
)?;
glossary.as_ref(),
)
.await?;
repository
.update_status_with_results(
task_id,
@@ -291,13 +312,14 @@ fn translation_task_result_params(
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
}
fn build_manual_translation_results(
async fn build_manual_translation_results(
task: &bat_infrastructure::PersistedTranslationTask,
index: &bat_infrastructure::OfficialTextUnitIndex,
params: &[TranslationTaskResultUpdateParam],
provider: &str,
provider_run_id: &str,
translated_unix_seconds: u64,
glossary: Option<&bat_infrastructure::SqliteGlossaryRepository>,
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
let index_by_id = index
.units
@@ -332,6 +354,53 @@ fn build_manual_translation_results(
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
));
}
let glossary_qa = if let Some(glossary) = glossary {
let context = bat_infrastructure::translation_memory_context(
&unit.destination,
unit.archive_entry.as_deref(),
unit.serialized_file.as_deref(),
unit.path_id,
unit.class_id,
unit.field_path.as_deref(),
unit.format.as_deref(),
unit.asset_name.as_deref(),
unit.text_source_kind.as_deref(),
&unit.context,
);
Some(
glossary
.diagnose(&unit.source_text, &context)
.await
.map_err(|error| anyhow::anyhow!("Glossary QA 失败:{error}"))?
.check_translation(&param.translated_text),
)
} else {
None
};
if let Some(qa) = glossary_qa.as_ref().filter(|qa| qa.status.is_blocked()) {
let Some(override_record) = param.glossary_override.as_ref() else {
return Err(anyhow::anyhow!(
"TextUnit {} 的 Glossary QA blocked;必须提供 glossary_override",
unit_id
));
};
if override_record.reviewer.trim().is_empty()
|| override_record.reason.trim().is_empty()
|| override_record.provenance.trim().is_empty()
|| override_record.confirmed_unix_seconds == 0
{
return Err(anyhow::anyhow!(
"TextUnit {} 的 glossary_override 不完整或 confirmed_unix_seconds 无效",
unit_id
));
}
let _ = qa;
} else if param.glossary_override.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
unit_id
));
}
results.push(bat_infrastructure::TranslationTaskUnitResult {
unit_id: unit_id.to_string(),
source_text: param.source_text.clone(),
@@ -341,6 +410,8 @@ fn build_manual_translation_results(
provider: provider.to_string(),
provider_run_id: provider_run_id.to_string(),
translated_unix_seconds,
glossary_qa,
glossary_override: param.glossary_override.clone(),
});
}
Ok(results)
@@ -104,7 +104,12 @@ pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<(
.ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
let (resource_root, release_id) = current_official_release(options)?;
let workbench = read_translation_workbench(path)?;
let validation = validate_translation_workbench(&resource_root, &release_id, &workbench)?;
let validation = validate_translation_workbench_with_glossary_path(
&resource_root,
&release_id,
&workbench,
options.glossary_path.as_deref(),
)?;
let data = serde_json::json!({
"official_release_id": release_id,
"resource_root": resource_root,
@@ -141,7 +146,50 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
.translation_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?;
let entry = set_translation(path, entry_id, text)?;
let glossary_override = match (
options.glossary_reviewer.as_deref(),
options.glossary_reason.as_deref(),
options.glossary_override_provenance.as_deref(),
) {
(None, None, None) => None,
(Some(reviewer), Some(reason), Some(provenance)) => Some(
bat_core::domain::GlossaryOverride {
reviewer: reviewer.to_string(),
reason: reason.to_string(),
provenance: provenance.to_string(),
confirmed_unix_seconds: unix_seconds_now(),
},
),
_ => {
return Err(anyhow::anyhow!(
"Glossary override 必须同时指定 --glossary-reviewer、--glossary-reason 和 --glossary-provenance"
))
}
};
let workbench = read_translation_workbench(path)?;
let glossary_path = options.glossary_path.clone().unwrap_or_else(|| {
bat_infrastructure::SqliteGlossaryRepository::repository_path(
&workbench.official_resource_root,
)
});
let entry = if std::fs::symlink_metadata(&glossary_path).is_ok() {
let (resource_root, _) = current_official_release(options)?;
set_translation_checked_with_glossary_path(
&resource_root,
path,
entry_id,
text,
glossary_override,
options.glossary_path.as_deref(),
)?
} else {
if glossary_override.is_some() {
return Err(anyhow::anyhow!(
"当前项目没有 Glossary 数据库,不能提交 Glossary override"
));
}
set_translation(path, entry_id, text)?
};
let data = serde_json::json!({
"translation_file": path,
"entry": entry,
@@ -232,6 +280,7 @@ pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Resul
let report = update_translation_task_status_report(
&options.state_dir,
Some(&serde_json::Value::Object(params)),
options.glossary_path.as_deref(),
)?;
print_json_value(options.output_format, &report)
}
@@ -316,6 +365,12 @@ pub(super) fn publish_localized_report(
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
));
}
validate_translation_workbench_with_glossary_path(
&resource_root,
&official_release_id,
&workbench,
options.glossary_path.as_deref(),
)?;
let operations = localized_patch_operations(&resource_root, &workbench)?;
let localized_release_id = options.localized_release_id.clone().or_else(|| {
options
+882
View File
@@ -0,0 +1,882 @@
//! Project-level Glossary V1 SQLite repository.
use crate::path_security::{
ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE,
};
use async_trait::async_trait;
use bat_core::domain::{
evaluate_glossary, validate_glossary_draft, GlossaryEvaluation, GlossaryHistoryRecord,
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossarySummary, GlossaryTerm,
GlossaryTermDraft, GlossaryTermSnapshot, TranslationMemoryContext,
};
use bat_core::repositories::GlossaryRepository;
use bat_core::{Error, Result};
use serde::de::DeserializeOwned;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Glossary SQLite schema version.
pub const GLOSSARY_SCHEMA_VERSION: u32 = 1;
/// Schema migration component.
pub const GLOSSARY_SCHEMA_COMPONENT: &str = "glossary";
/// Default project-level glossary file.
pub const GLOSSARY_REPOSITORY_FILE: &str = "glossary.sqlite";
/// SQLite-backed project Glossary repository.
#[derive(Debug, Clone)]
pub struct SqliteGlossaryRepository {
pool: SqlitePool,
}
impl SqliteGlossaryRepository {
/// Creates or opens a glossary database.
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with(path.as_ref(), true).await
}
/// Opens an existing glossary database without creating it.
pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with(path.as_ref(), false).await
}
/// Returns the project-level glossary path for an official release root.
pub fn repository_path(resource_root: &Path) -> PathBuf {
if resource_root
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name == "versions")
{
if let Some(output_root) = resource_root.parent().and_then(Path::parent) {
return output_root.join(GLOSSARY_REPOSITORY_FILE);
}
}
resource_root.join(GLOSSARY_REPOSITORY_FILE)
}
async fn open_with(path: &Path, create_if_missing: bool) -> Result<Self> {
let absolute = lexical_absolute(path).map_err(Error::InvalidArgument)?;
let parent = absolute.parent().ok_or_else(|| {
Error::InvalidArgument(format!("Glossary 数据库缺少父目录:{}", absolute.display()))
})?;
ensure_safe_directory_path(parent, "Glossary 数据库").map_err(Error::InvalidArgument)?;
if create_if_missing {
tokio::fs::create_dir_all(parent).await?;
ensure_safe_directory_path(parent, "Glossary 数据库")
.map_err(Error::InvalidArgument)?;
} else if !absolute.is_file() {
return Err(Error::NotFound(absolute.display().to_string()));
}
if let Ok(metadata) = std::fs::symlink_metadata(&absolute) {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(Error::InvalidArgument(format!(
"Glossary 数据库必须是普通文件:{}",
absolute.display()
)));
}
}
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", absolute.display()))
.map_err(|error| Error::Other(error.into()))?
.create_if_missing(create_if_missing)
.journal_mode(SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(30));
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.map_err(db_error)?;
if create_if_missing {
set_file_mode(&absolute, STATE_FILE_MODE, "Glossary 数据库")
.map_err(Error::InvalidArgument)?;
}
let repository = Self { pool };
repository.init_schema().await?;
Ok(repository)
}
async fn init_schema(&self) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_migrations (
component TEXT PRIMARY KEY NOT NULL,
version INTEGER NOT NULL CHECK(version >= 1)
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS glossary_terms (
term_id TEXT PRIMARY KEY NOT NULL,
source_term TEXT NOT NULL,
aliases_json TEXT NOT NULL,
recommended_translation TEXT NOT NULL,
allowed_translations_json TEXT NOT NULL,
source_language TEXT,
target_language TEXT,
category TEXT,
priority INTEGER NOT NULL,
scope_json TEXT NOT NULL,
review_status TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_ref TEXT,
source_author TEXT,
source_note TEXT,
source_observed_unix_seconds INTEGER NOT NULL,
created_unix_seconds INTEGER NOT NULL,
updated_unix_seconds INTEGER NOT NULL,
CHECK(length(term_id) > 0),
CHECK(length(source_term) > 0),
CHECK(length(recommended_translation) > 0),
CHECK(review_status IN ('draft', 'approved', 'deprecated', 'rejected')),
CHECK(source_kind IN ('manual', 'imported'))
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
ensure_column(
&self.pool,
"glossary_terms",
"source_observed_unix_seconds",
"INTEGER NOT NULL DEFAULT 1",
)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS glossary_term_history (
history_id TEXT PRIMARY KEY NOT NULL,
term_id TEXT NOT NULL,
action TEXT NOT NULL,
reviewer TEXT,
reason TEXT,
source_json TEXT NOT NULL,
review_status TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
observed_unix_seconds INTEGER NOT NULL,
FOREIGN KEY(term_id) REFERENCES glossary_terms(term_id)
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS glossary_term_deletions (
deletion_id TEXT PRIMARY KEY NOT NULL,
term_id TEXT NOT NULL,
reviewer TEXT NOT NULL,
reason TEXT NOT NULL,
source_json TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
history_json TEXT NOT NULL,
observed_unix_seconds INTEGER NOT NULL
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_status
ON glossary_terms(review_status, priority DESC, term_id)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_source_term
ON glossary_terms(source_term)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
let current: Option<i64> =
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
.bind(GLOSSARY_SCHEMA_COMPONENT)
.fetch_optional(&self.pool)
.await
.map_err(db_error)?;
if current.is_some_and(|version| version > i64::from(GLOSSARY_SCHEMA_VERSION)) {
return Err(Error::InvalidArgument(format!(
"不支持的 Glossary schema 版本:{}",
current.unwrap_or_default()
)));
}
sqlx::query(
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
)
.bind(GLOSSARY_SCHEMA_COMPONENT)
.bind(i64::from(GLOSSARY_SCHEMA_VERSION))
.execute(&self.pool)
.await
.map_err(db_error)?;
Ok(())
}
/// Returns one term with complete history.
pub async fn find(&self, term_id: &str) -> Result<GlossaryTerm> {
let row = sqlx::query(
"SELECT term_id, source_term, aliases_json, recommended_translation,
allowed_translations_json, source_language, target_language, category,
priority, scope_json, review_status, source_kind, source_ref,
source_author, source_note, source_observed_unix_seconds,
created_unix_seconds, updated_unix_seconds
FROM glossary_terms WHERE term_id = ?1",
)
.bind(term_id)
.fetch_optional(&self.pool)
.await
.map_err(db_error)?
.ok_or_else(|| Error::NotFound(term_id.to_string()))?;
let mut term = row_to_term(row)?;
let history_rows = sqlx::query(
"SELECT history_id, action, reviewer, reason, source_json, review_status,
snapshot_json, observed_unix_seconds
FROM glossary_term_history WHERE term_id = ?1
ORDER BY observed_unix_seconds ASC, history_id ASC",
)
.bind(term_id)
.fetch_all(&self.pool)
.await
.map_err(db_error)?;
term.history = history_rows
.into_iter()
.map(row_to_history)
.collect::<Result<Vec<_>>>()?;
Ok(term)
}
/// Queries terms, including non-approved terms for review.
pub async fn query(
&self,
source_text: Option<&str>,
category: Option<&str>,
review_status: Option<GlossaryReviewStatus>,
limit: usize,
) -> Result<Vec<GlossaryTerm>> {
if !(1..=1000).contains(&limit) {
return Err(Error::InvalidArgument(
"Glossary query limit 必须在 1..=1000 范围内".to_string(),
));
}
let candidates = self.load_terms(category, review_status).await?;
let mut terms = Vec::new();
for term in candidates {
if source_text.is_none_or(|source| {
let mut spellings = vec![term.definition.source_term.as_str()];
spellings.extend(term.definition.aliases.iter().map(String::as_str));
spellings
.into_iter()
.filter(|spelling| !spelling.is_empty())
.any(|spelling| source.contains(spelling))
}) {
terms.push(term);
if terms.len() >= limit {
break;
}
}
}
Ok(terms)
}
async fn load_terms(
&self,
category: Option<&str>,
review_status: Option<GlossaryReviewStatus>,
) -> Result<Vec<GlossaryTerm>> {
let rows = sqlx::query(
"SELECT term_id FROM glossary_terms
WHERE (?1 IS NULL OR category = ?1)
AND (?2 IS NULL OR review_status = ?2)
ORDER BY priority DESC, term_id ASC",
)
.bind(category)
.bind(review_status.map(|value| value.as_str()))
.fetch_all(&self.pool)
.await
.map_err(db_error)?;
let mut terms = Vec::new();
for row in rows {
let term_id: String = row.try_get("term_id").map_err(db_error)?;
terms.push(self.find(&term_id).await?);
}
Ok(terms)
}
/// Returns review-state counts.
pub async fn summary(&self) -> Result<GlossarySummary> {
let row = sqlx::query(
"SELECT COUNT(*) AS term_count,
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count,
SUM(CASE WHEN review_status = 'draft' THEN 1 ELSE 0 END) AS draft_count,
SUM(CASE WHEN review_status = 'deprecated' THEN 1 ELSE 0 END) AS deprecated_count,
SUM(CASE WHEN review_status = 'rejected' THEN 1 ELSE 0 END) AS rejected_count
FROM glossary_terms",
)
.fetch_one(&self.pool)
.await
.map_err(db_error)?;
Ok(GlossarySummary {
schema_version: GLOSSARY_SCHEMA_VERSION,
term_count: row.try_get::<i64, _>("term_count").map_err(db_error)? as u64,
approved_count: row.try_get::<i64, _>("approved_count").map_err(db_error)? as u64,
draft_count: row.try_get::<i64, _>("draft_count").map_err(db_error)? as u64,
deprecated_count: row
.try_get::<i64, _>("deprecated_count")
.map_err(db_error)? as u64,
rejected_count: row.try_get::<i64, _>("rejected_count").map_err(db_error)? as u64,
})
}
/// Adds a term and records its source snapshot.
pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?;
if draft.review_status != GlossaryReviewStatus::Draft {
return Err(Error::InvalidArgument(
"Glossary add 只能创建 draft;请通过 review/approve 使术语生效".to_string(),
));
}
let now = draft.source.observed_unix_seconds;
let term = term_from_draft(&draft, now, now);
let mut transaction = self.pool.begin().await.map_err(db_error)?;
let existing: Option<String> =
sqlx::query_scalar("SELECT term_id FROM glossary_terms WHERE term_id = ?1")
.bind(&draft.term_id)
.fetch_optional(&mut *transaction)
.await
.map_err(db_error)?;
if existing.is_some() {
return Err(Error::InvalidArgument(format!(
"Glossary term_id 已存在:{}",
draft.term_id
)));
}
insert_term(&mut transaction, &term).await?;
insert_history(&mut transaction, &term, "created", None, None, now).await?;
transaction.commit().await.map_err(db_error)?;
Ok(term)
}
/// Replaces a term definition and records the previous source history.
pub async fn update(
&self,
draft: GlossaryTermDraft,
reviewer: &str,
reason: Option<String>,
) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?;
if draft.review_status != GlossaryReviewStatus::Draft {
return Err(Error::InvalidArgument(
"Glossary update 只能写入 draft;修改 approved 术语后必须重新 approve".to_string(),
));
}
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary update reviewer 不能为空".to_string(),
));
}
let current = self.find(&draft.term_id).await?;
let now = draft.source.observed_unix_seconds;
let term = term_from_draft(&draft, current.created_unix_seconds, now);
let mut transaction = self.pool.begin().await.map_err(db_error)?;
update_term(&mut transaction, &term).await?;
insert_history(
&mut transaction,
&term,
"updated",
Some(reviewer.trim()),
reason.as_deref(),
now,
)
.await?;
transaction.commit().await.map_err(db_error)?;
self.find(&draft.term_id).await
}
/// Permanently removes a term and its stored history after explicit review.
pub async fn delete(
&self,
term_id: &str,
reviewer: &str,
reason: &str,
) -> Result<GlossaryTerm> {
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary delete reviewer 不能为空".to_string(),
));
}
if reason.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary delete reason 不能为空".to_string(),
));
}
let term = self.find(term_id).await?;
let mut transaction = self.pool.begin().await.map_err(db_error)?;
let source_json = json(&term.source)?;
let snapshot_json = json(&term.definition)?;
let history_json = json(&term.history)?;
let observed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut deletion_hasher = blake3::Hasher::new();
for value in [term_id, reviewer.trim(), reason.trim()] {
deletion_hasher.update(value.as_bytes());
deletion_hasher.update(&[0]);
}
deletion_hasher.update(&observed.to_le_bytes());
deletion_hasher.update(
&SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
);
sqlx::query(
"INSERT INTO glossary_term_deletions (
deletion_id, term_id, reviewer, reason, source_json,
snapshot_json, history_json, observed_unix_seconds
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)
.bind(format!("gld-{}", deletion_hasher.finalize().to_hex()))
.bind(term_id)
.bind(reviewer.trim())
.bind(reason.trim())
.bind(source_json)
.bind(snapshot_json)
.bind(history_json)
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
.execute(&mut *transaction)
.await
.map_err(db_error)?;
sqlx::query("DELETE FROM glossary_term_history WHERE term_id = ?1")
.bind(term_id)
.execute(&mut *transaction)
.await
.map_err(db_error)?;
sqlx::query("DELETE FROM glossary_terms WHERE term_id = ?1")
.bind(term_id)
.execute(&mut *transaction)
.await
.map_err(db_error)?;
transaction.commit().await.map_err(db_error)?;
Ok(term)
}
/// Changes review state and records a source/review history entry.
pub async fn review(
&self,
term_id: &str,
status: GlossaryReviewStatus,
reviewer: &str,
reason: Option<String>,
) -> Result<GlossaryTerm> {
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary reviewer 不能为空".to_string(),
));
}
if !matches!(
status,
GlossaryReviewStatus::Approved
| GlossaryReviewStatus::Deprecated
| GlossaryReviewStatus::Rejected
) {
return Err(Error::InvalidArgument(
"Glossary review 只允许 approved、deprecated 或 rejected".to_string(),
));
}
let mut term = self.find(term_id).await?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
term.review_status = status;
term.updated_unix_seconds = now;
let mut transaction = self.pool.begin().await.map_err(db_error)?;
sqlx::query(
"UPDATE glossary_terms SET review_status = ?2, updated_unix_seconds = ?3
WHERE term_id = ?1",
)
.bind(term_id)
.bind(status.as_str())
.bind(i64::try_from(now).unwrap_or(i64::MAX))
.execute(&mut *transaction)
.await
.map_err(db_error)?;
insert_history(
&mut transaction,
&term,
status.as_str(),
Some(reviewer.trim()),
reason.as_deref(),
now,
)
.await?;
transaction.commit().await.map_err(db_error)?;
self.find(term_id).await
}
/// Evaluates approved terms for one TextUnit.
pub async fn diagnose(
&self,
source_text: &str,
context: &TranslationMemoryContext,
) -> Result<GlossaryEvaluation> {
let terms = self
.load_terms(None, Some(GlossaryReviewStatus::Approved))
.await?;
Ok(evaluate_glossary(&terms, source_text, context))
}
}
#[async_trait]
impl GlossaryRepository for SqliteGlossaryRepository {
async fn evaluate(
&self,
source_text: &str,
context: &TranslationMemoryContext,
) -> Result<GlossaryEvaluation> {
self.diagnose(source_text, context).await
}
}
fn term_from_draft(draft: &GlossaryTermDraft, created: u64, updated: u64) -> GlossaryTerm {
GlossaryTerm {
term_id: draft.term_id.clone(),
definition: draft.definition.clone(),
review_status: draft.review_status,
source: draft.source.clone(),
history: Vec::new(),
created_unix_seconds: created,
updated_unix_seconds: updated,
}
}
async fn insert_term(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
term: &GlossaryTerm,
) -> Result<()> {
sqlx::query(
"INSERT INTO glossary_terms (
term_id, source_term, aliases_json, recommended_translation,
allowed_translations_json, source_language, target_language, category,
priority, scope_json, review_status, source_kind, source_ref,
source_author, source_note, source_observed_unix_seconds,
created_unix_seconds, updated_unix_seconds
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
)
.bind(&term.term_id)
.bind(&term.definition.source_term)
.bind(json(&term.definition.aliases)?)
.bind(&term.definition.recommended_translation)
.bind(json(&term.definition.allowed_translations)?)
.bind(&term.definition.source_language)
.bind(&term.definition.target_language)
.bind(&term.definition.category)
.bind(term.definition.priority)
.bind(json(&term.definition.scope)?)
.bind(term.review_status.as_str())
.bind(term.source.source_kind.as_str())
.bind(&term.source.source_ref)
.bind(&term.source.source_author)
.bind(&term.source.source_note)
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
.bind(i64::try_from(term.created_unix_seconds).unwrap_or(i64::MAX))
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
.execute(&mut **transaction)
.await
.map_err(db_error)?;
Ok(())
}
async fn update_term(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
term: &GlossaryTerm,
) -> Result<()> {
sqlx::query(
"UPDATE glossary_terms SET source_term = ?2, aliases_json = ?3,
recommended_translation = ?4, allowed_translations_json = ?5,
source_language = ?6, target_language = ?7, category = ?8,
priority = ?9, scope_json = ?10, review_status = ?11, source_kind = ?12,
source_ref = ?13, source_author = ?14, source_note = ?15,
source_observed_unix_seconds = ?16, updated_unix_seconds = ?17 WHERE term_id = ?1",
)
.bind(&term.term_id)
.bind(&term.definition.source_term)
.bind(json(&term.definition.aliases)?)
.bind(&term.definition.recommended_translation)
.bind(json(&term.definition.allowed_translations)?)
.bind(&term.definition.source_language)
.bind(&term.definition.target_language)
.bind(&term.definition.category)
.bind(term.definition.priority)
.bind(json(&term.definition.scope)?)
.bind(term.review_status.as_str())
.bind(term.source.source_kind.as_str())
.bind(&term.source.source_ref)
.bind(&term.source.source_author)
.bind(&term.source.source_note)
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
.execute(&mut **transaction)
.await
.map_err(db_error)?;
Ok(())
}
async fn insert_history(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
term: &GlossaryTerm,
action: &str,
reviewer: Option<&str>,
reason: Option<&str>,
observed: u64,
) -> Result<()> {
let source_json = json(&term.source)?;
let snapshot_json = json(&term.definition)?;
let mut history_hasher = blake3::Hasher::new();
for value in [
term.term_id.as_str(),
action,
reviewer.unwrap_or_default(),
reason.unwrap_or_default(),
source_json.as_str(),
snapshot_json.as_str(),
] {
history_hasher.update(value.as_bytes());
history_hasher.update(&[0]);
}
history_hasher.update(&observed.to_le_bytes());
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
history_hasher.update(&nonce.to_le_bytes());
let history_id = format!("glh-{}", history_hasher.finalize().to_hex());
sqlx::query(
"INSERT INTO glossary_term_history (
history_id, term_id, action, reviewer, reason, source_json,
review_status, snapshot_json, observed_unix_seconds
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)
.bind(history_id)
.bind(&term.term_id)
.bind(action)
.bind(reviewer)
.bind(reason.filter(|value| !value.trim().is_empty()))
.bind(source_json)
.bind(term.review_status.as_str())
.bind(snapshot_json)
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
.execute(&mut **transaction)
.await
.map_err(db_error)?;
Ok(())
}
fn row_to_term(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryTerm> {
Ok(GlossaryTerm {
term_id: row.try_get("term_id").map_err(db_error)?,
definition: GlossaryTermSnapshot {
source_term: row.try_get("source_term").map_err(db_error)?,
aliases: parse_json(row.try_get("aliases_json").map_err(db_error)?)?,
recommended_translation: row.try_get("recommended_translation").map_err(db_error)?,
allowed_translations: parse_json(
row.try_get("allowed_translations_json").map_err(db_error)?,
)?,
source_language: row.try_get("source_language").map_err(db_error)?,
target_language: row.try_get("target_language").map_err(db_error)?,
category: row.try_get("category").map_err(db_error)?,
priority: row.try_get("priority").map_err(db_error)?,
scope: parse_json(row.try_get("scope_json").map_err(db_error)?)?,
},
review_status: parse_review_status(
row.try_get::<String, _>("review_status")
.map_err(db_error)?
.as_str(),
)?,
source: GlossarySourceRecord {
source_kind: parse_source_kind(
row.try_get::<String, _>("source_kind")
.map_err(db_error)?
.as_str(),
)?,
source_ref: row.try_get("source_ref").map_err(db_error)?,
source_author: row.try_get("source_author").map_err(db_error)?,
source_note: row.try_get("source_note").map_err(db_error)?,
observed_unix_seconds: to_u64(
row.try_get("source_observed_unix_seconds")
.map_err(db_error)?,
"source",
)?,
},
history: Vec::new(),
created_unix_seconds: to_u64(
row.try_get("created_unix_seconds").map_err(db_error)?,
"created",
)?,
updated_unix_seconds: to_u64(
row.try_get("updated_unix_seconds").map_err(db_error)?,
"updated",
)?,
})
}
fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryHistoryRecord> {
Ok(GlossaryHistoryRecord {
history_id: row.try_get("history_id").map_err(db_error)?,
action: row.try_get("action").map_err(db_error)?,
reviewer: row.try_get("reviewer").map_err(db_error)?,
reason: row.try_get("reason").map_err(db_error)?,
source: parse_json(row.try_get("source_json").map_err(db_error)?)?,
review_status: parse_review_status(
row.try_get::<String, _>("review_status")
.map_err(db_error)?
.as_str(),
)?,
snapshot: parse_json(row.try_get("snapshot_json").map_err(db_error)?)?,
observed_unix_seconds: to_u64(
row.try_get("observed_unix_seconds").map_err(db_error)?,
"history",
)?,
})
}
fn parse_json<T: DeserializeOwned>(value: String) -> Result<T> {
serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string()))
}
fn json<T: serde::Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).map_err(|error| Error::Serialization(error.to_string()))
}
fn parse_review_status(value: &str) -> Result<GlossaryReviewStatus> {
GlossaryReviewStatus::parse(value)
.ok_or_else(|| Error::Serialization(format!("未知 Glossary review status{value}")))
}
fn parse_source_kind(value: &str) -> Result<GlossarySourceKind> {
GlossarySourceKind::parse(value)
.ok_or_else(|| Error::Serialization(format!("未知 Glossary source kind{value}")))
}
fn to_u64(value: i64, label: &str) -> Result<u64> {
u64::try_from(value).map_err(|_| Error::Serialization(format!("Glossary {label} 时间无效")))
}
fn db_error(error: sqlx::Error) -> Error {
Error::Other(error.into())
}
async fn ensure_column(
pool: &SqlitePool,
table: &str,
column: &str,
definition: &str,
) -> Result<()> {
let columns = sqlx::query(&format!("PRAGMA table_info({table})"))
.fetch_all(pool)
.await
.map_err(db_error)?;
let exists = columns.iter().any(|row| {
row.try_get::<String, _>("name")
.map(|name| name == column)
.unwrap_or(false)
});
if !exists {
sqlx::query(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {definition}"
))
.execute(pool)
.await
.map_err(db_error)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn draft(status: GlossaryReviewStatus) -> GlossaryTermDraft {
GlossaryTermDraft {
term_id: "term-sensei".to_string(),
definition: GlossaryTermSnapshot {
source_term: "Sensei".to_string(),
aliases: vec!["Teacher".to_string()],
recommended_translation: "老师".to_string(),
allowed_translations: vec!["老师大人".to_string()],
source_language: Some("en".to_string()),
target_language: Some("zh-Hans".to_string()),
category: Some("person".to_string()),
priority: 10,
scope: BTreeMap::new(),
},
review_status: status,
source: GlossarySourceRecord {
source_kind: GlossarySourceKind::Manual,
source_ref: Some("test".to_string()),
source_author: Some("tester".to_string()),
source_note: None,
observed_unix_seconds: 1,
},
}
}
#[tokio::test]
async fn sqlite_glossary_preserves_history_and_only_approved_terms_match() {
let temp = tempfile::TempDir::new().unwrap();
let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite"))
.await
.unwrap();
assert!(repository
.add(draft(GlossaryReviewStatus::Approved))
.await
.is_err());
repository
.add(draft(GlossaryReviewStatus::Draft))
.await
.unwrap();
let before = repository
.diagnose("Sensei", &BTreeMap::new())
.await
.unwrap();
assert!(before.constraints.is_empty());
repository
.review(
"term-sensei",
GlossaryReviewStatus::Approved,
"reviewer",
Some("ok".to_string()),
)
.await
.unwrap();
let after = repository
.diagnose("Sensei", &BTreeMap::new())
.await
.unwrap();
assert_eq!(after.constraints.len(), 1);
let term = repository.find("term-sensei").await.unwrap();
assert_eq!(term.history.len(), 2);
assert_eq!(term.history[1].action, "approved");
let summary = repository.summary().await.unwrap();
assert_eq!(summary.approved_count, 1);
let deleted = repository
.delete("term-sensei", "reviewer", "remove duplicate")
.await
.unwrap();
assert_eq!(deleted.history.len(), 2);
assert!(repository.find("term-sensei").await.is_err());
assert_eq!(repository.summary().await.unwrap().term_count, 0);
let deletion_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM glossary_term_deletions")
.fetch_one(&repository.pool)
.await
.unwrap();
assert_eq!(deletion_count, 1);
}
}
+10 -3
View File
@@ -13,6 +13,7 @@
pub mod cas;
mod curl_transfer;
pub mod downloader;
pub mod glossary;
pub mod import;
pub mod localized_patch;
pub mod official_changes;
@@ -43,6 +44,10 @@ pub use downloader::{
DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY,
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
};
pub use glossary::{
SqliteGlossaryRepository, GLOSSARY_REPOSITORY_FILE, GLOSSARY_SCHEMA_COMPONENT,
GLOSSARY_SCHEMA_VERSION,
};
pub use import::{
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
ResourceImportService,
@@ -169,9 +174,11 @@ pub use translation_workflow::{
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
export_translation_workbench, get_translation_entry, localized_patch_operations,
localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation,
unset_translation, validate_translation_workbench, write_translation_workbench,
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
set_translation_checked, set_translation_checked_with_glossary_path, unset_translation,
validate_translation_workbench, validate_translation_workbench_with_glossary_path,
write_translation_workbench, RepackOperation, RepackReport, RepackSpec, TranslationWorkbench,
TranslationWorkbenchEntry, TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION,
TRANSLATION_WORKBENCH_VERSION,
};
/// Infrastructure 版本号
+6
View File
@@ -99,6 +99,12 @@ pub struct LocalizedPatchOperationMetadata {
pub translation_memory_record_id: Option<String>,
/// Review state used by the publication input.
pub review_status: String,
/// Deterministic Glossary QA recorded for this translation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
/// Explicit confirmation for a blocking Glossary deviation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
}
/// Configuration for one localized release publication.
+10
View File
@@ -173,6 +173,12 @@ pub struct TranslationTaskUnitResult {
pub provider_run_id: String,
/// Result persistence time.
pub translated_unix_seconds: u64,
/// Deterministic Glossary QA result, when a project Glossary was available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
/// Explicit human confirmation for a blocking Glossary deviation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
}
/// Source of one persisted TextUnit translation result.
@@ -1808,6 +1814,8 @@ mod tests {
provider: "manual".to_string(),
provider_run_id: "manual-run-1".to_string(),
translated_unix_seconds: 321,
glossary_qa: None,
glossary_override: None,
};
let updated = repository
@@ -1927,6 +1935,8 @@ mod tests {
provider: "mock".to_string(),
provider_run_id: second_run.clone(),
translated_unix_seconds: 1,
glossary_qa: None,
glossary_override: None,
};
assert!(repository
+349 -42
View File
@@ -5,6 +5,7 @@
//! 租约和任务结果写入 release 级 `translation-tasks.sqlite`,跨 release 的
//! Translation Memory 写入项目级独立 SQLite 数据库。
use crate::glossary::SqliteGlossaryRepository;
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
use crate::official_textunit_queue::read_textunit_task_queue_at;
use crate::translation_memory::{translation_memory_context, SqliteTranslationMemoryRepository};
@@ -14,9 +15,10 @@ use crate::translation_tasks::{
};
use async_trait::async_trait;
use bat_core::domain::{
TranslationMemoryDraft, TranslationMemorySourceKind, TranslationMemorySourceTrace,
GlossaryConstraint, GlossaryQaReport, TranslationMemoryDraft, TranslationMemorySourceKind,
TranslationMemorySourceTrace,
};
use bat_core::repositories::TranslationMemoryRepository;
use bat_core::repositories::{GlossaryRepository, TranslationMemoryRepository};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
@@ -98,6 +100,9 @@ pub struct TranslationWorkerConfig {
/// Translation Memory SQLite path. `None` uses the output-root default.
#[serde(skip_serializing_if = "Option::is_none")]
pub translation_memory_path: Option<PathBuf>,
/// Project-level Glossary SQLite path. `None` uses the output-root default.
#[serde(skip_serializing_if = "Option::is_none")]
pub glossary_path: Option<PathBuf>,
}
impl Default for TranslationWorkerConfig {
@@ -112,6 +117,7 @@ impl Default for TranslationWorkerConfig {
max_tasks: None,
worker_id: format!("bat-worker-{}", std::process::id()),
translation_memory_path: None,
glossary_path: None,
}
}
}
@@ -180,6 +186,9 @@ pub struct TranslationProviderUnit {
/// 解析器保留的上下文,包括可选 `crowdin_string_id`。
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub context: BTreeMap<String, String>,
/// Approved Glossary constraints for this TextUnit.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub glossary_constraints: Vec<GlossaryConstraint>,
}
/// 一次 provider 批处理请求。
@@ -596,6 +605,14 @@ pub struct TranslationWorkerReport {
pub provider_unit_count: usize,
/// Translation Memory diagnostics that did not invalidate provider work.
pub translation_memory_failures: Vec<String>,
/// Project-level Glossary database path used by this run.
pub glossary_path: PathBuf,
/// Whether a Glossary database was available.
pub glossary_available: bool,
/// TextUnits whose Glossary QA blocked automatic reuse or publication.
pub glossary_blocked_count: usize,
/// Glossary diagnostics that did not abort worker startup.
pub glossary_failures: Vec<String>,
}
/// worker 失败诊断。
@@ -623,6 +640,8 @@ struct WorkerStats {
provider_unit_count: AtomicUsize,
failures: Mutex<Vec<TranslationWorkerFailure>>,
translation_memory_failures: Mutex<Vec<String>>,
glossary_blocked_count: AtomicUsize,
glossary_failures: Mutex<Vec<String>>,
}
struct WorkerTaskContext<'a> {
@@ -635,6 +654,7 @@ struct WorkerTaskContext<'a> {
retry_backoff: Duration,
stats: &'a WorkerStats,
translation_memory: Option<&'a dyn TranslationMemoryRepository>,
glossary: Option<&'a dyn GlossaryRepository>,
}
/// 运行一个 provider worker 轮次。
@@ -715,6 +735,28 @@ async fn run_translation_worker_with_provider_and_cancellation(
)),
),
};
let glossary_path = config
.glossary_path
.clone()
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
let (glossary, glossary_startup_failure) = if std::fs::symlink_metadata(&glossary_path).is_ok()
{
match SqliteGlossaryRepository::open(&glossary_path).await {
Ok(repository) => (Some(Arc::new(repository)), None),
Err(error) => (
None,
Some(format!(
"打开 Glossary 数据库失败 {}{error}",
glossary_path.display()
)),
),
}
} else {
(None, None)
};
if let Some(failure) = glossary_startup_failure.as_deref() {
return Err(anyhow::anyhow!(failure.to_string()));
}
let repository = Arc::new(
SqliteTranslationTaskRepository::new(SqliteTranslationTaskRepository::repository_path(
resource_root,
@@ -754,6 +796,7 @@ async fn run_translation_worker_with_provider_and_cancellation(
let lease_seconds = config.lease_seconds;
let retry_backoff = config.retry_backoff;
let translation_memory = translation_memory.clone();
let glossary = glossary.clone();
let should_cancel = Arc::clone(&should_cancel);
handles.push(tokio::spawn(async move {
loop {
@@ -791,6 +834,9 @@ async fn run_translation_worker_with_provider_and_cancellation(
translation_memory: translation_memory
.as_deref()
.map(|repository| repository as &dyn TranslationMemoryRepository),
glossary: glossary
.as_deref()
.map(|repository| repository as &dyn GlossaryRepository),
},
&task,
)
@@ -842,6 +888,11 @@ async fn run_translation_worker_with_provider_and_cancellation(
.lock()
.map_err(|_| anyhow::anyhow!("读取 Translation Memory 诊断时 mutex poisoned"))?
.clone();
let glossary_failures = stats
.glossary_failures
.lock()
.map_err(|_| anyhow::anyhow!("读取 Glossary 诊断时 mutex poisoned"))?
.clone();
Ok(TranslationWorkerReport {
command: "translation-worker",
status: if failed_count == 0 {
@@ -863,6 +914,10 @@ async fn run_translation_worker_with_provider_and_cancellation(
translation_memory_hit_count: stats.translation_memory_hit_count.load(Ordering::Relaxed),
provider_unit_count: stats.provider_unit_count.load(Ordering::Relaxed),
translation_memory_failures,
glossary_path,
glossary_available: glossary.is_some(),
glossary_blocked_count: stats.glossary_blocked_count.load(Ordering::Relaxed),
glossary_failures,
})
}
@@ -873,35 +928,99 @@ async fn process_claimed_task(
let task_units = task_index_units(task, context.index)?;
let mut results = BTreeMap::new();
let mut provider_units = Vec::new();
let mut glossary_evaluations = BTreeMap::new();
for unit in &task_units {
let source_context = translation_memory_context(
&unit.destination,
unit.archive_entry.as_deref(),
unit.serialized_file.as_deref(),
unit.path_id,
unit.class_id,
unit.field_path.as_deref(),
unit.format.as_deref(),
unit.asset_name.as_deref(),
unit.text_source_kind.as_deref(),
&unit.context,
);
let glossary_evaluation = if let Some(glossary) = context.glossary {
match glossary.evaluate(&unit.source_text, &source_context).await {
Ok(evaluation) => evaluation,
Err(error) => {
record_glossary_failure(
context,
format!(
"任务 {} TextUnit {} 查询失败:{}",
task.task.task_id, unit.id, error
),
)?;
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
format!("TextUnit {} 无法完成 Glossary QA;自动翻译已阻止", unit.id),
),
&results,
)
.await?;
return Ok(());
}
}
} else {
bat_core::domain::GlossaryEvaluation {
constraints: Vec::new(),
diagnostics: Vec::new(),
blocked: false,
}
};
if glossary_evaluation.blocked {
context
.stats
.glossary_blocked_count
.fetch_add(1, Ordering::Relaxed);
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
format!(
"TextUnit {} 的 Glossary 存在未解决冲突,必须人工确认后才能继续",
unit.id
),
),
&results,
)
.await?;
return Ok(());
}
glossary_evaluations.insert(unit.id.clone(), glossary_evaluation);
if let Some(translation_memory) = context.translation_memory {
let source_context = translation_memory_context(
&unit.destination,
unit.archive_entry.as_deref(),
unit.serialized_file.as_deref(),
unit.path_id,
unit.class_id,
unit.field_path.as_deref(),
unit.format.as_deref(),
unit.asset_name.as_deref(),
unit.text_source_kind.as_deref(),
&unit.context,
);
match translation_memory
.find_matches(&unit.source_text, &source_context, 1)
.await
{
Ok(matches) => {
if let Some(found) = matches.into_iter().find(|item| item.can_auto_reuse) {
context
.stats
.translation_memory_hit_count
.fetch_add(1, Ordering::Relaxed);
results.insert(
unit.id.clone(),
translation_memory_result(task, unit, &found.entry),
);
continue;
let qa = glossary_evaluations
.get(&unit.id)
.expect("Glossary evaluation inserted before TM lookup")
.check_translation(&found.entry.translated_text);
if qa.status.is_blocked() {
context
.stats
.glossary_blocked_count
.fetch_add(1, Ordering::Relaxed);
} else {
context
.stats
.translation_memory_hit_count
.fetch_add(1, Ordering::Relaxed);
results.insert(
unit.id.clone(),
translation_memory_result(task, unit, &found.entry, qa),
);
continue;
}
}
}
Err(error) => {
@@ -923,7 +1042,7 @@ async fn process_claimed_task(
.stats
.provider_unit_count
.fetch_add(provider_units.len(), Ordering::Relaxed);
let request = match provider_request(task, &provider_units) {
let request = match provider_request(task, &provider_units, &glossary_evaluations) {
Ok(request) => request,
Err(error) => {
record_provider_failure(
@@ -941,23 +1060,48 @@ async fn process_claimed_task(
};
match context.provider.translate(request.clone()).await {
Ok(response) => {
let provider_results =
match validate_provider_response(&request, response, context.provider_name) {
Ok(results) => results,
Err(error) => {
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
error.to_string(),
),
&results,
)
.await?;
return Ok(());
}
};
let provider_results = match validate_provider_response(
&request,
response,
context.provider_name,
&glossary_evaluations,
) {
Ok(results) => results,
Err(error) => {
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
error.to_string(),
),
&results,
)
.await?;
return Ok(());
}
};
if provider_results.iter().any(|result| {
result
.glossary_qa
.as_ref()
.is_some_and(|qa| qa.status.is_blocked())
}) {
for result in &provider_results {
results.insert(result.unit_id.clone(), result.clone());
}
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
"provider 译文未通过 Glossary QA;需要人工 override 后才能发布",
),
&results,
)
.await?;
return Ok(());
}
for result in &provider_results {
results.insert(result.unit_id.clone(), result.clone());
if let Some(unit) = provider_units.iter().find(|unit| unit.id == result.unit_id)
@@ -986,6 +1130,13 @@ async fn process_claimed_task(
observed_unix_seconds: unix_seconds_now(),
};
if let Some(translation_memory) = context.translation_memory {
if results
.get(&result.unit_id)
.and_then(|value| value.glossary_qa.as_ref())
.is_some_and(|qa| qa.status.is_blocked())
{
continue;
}
if let Err(error) = translation_memory.upsert_candidate(draft).await {
record_translation_memory_failure(
context,
@@ -1066,6 +1217,7 @@ fn task_index_units<'a>(
fn provider_request(
task: &PersistedTranslationTask,
index_units: &[&OfficialTextUnitIndexUnit],
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
) -> anyhow::Result<TranslationProviderRequest> {
let provider_run_id = task
.provider_run_id
@@ -1079,7 +1231,16 @@ fn provider_request(
archive_entry: task.task.archive_entry.clone(),
units: index_units
.iter()
.map(|unit| provider_unit(task, unit))
.map(|unit| {
provider_unit(
task,
unit,
glossary_evaluations
.get(&unit.id)
.map(|evaluation| evaluation.constraints.clone())
.unwrap_or_default(),
)
})
.collect(),
})
}
@@ -1087,6 +1248,7 @@ fn provider_request(
fn provider_unit(
task: &PersistedTranslationTask,
unit: &OfficialTextUnitIndexUnit,
glossary_constraints: Vec<GlossaryConstraint>,
) -> TranslationProviderUnit {
TranslationProviderUnit {
unit_id: unit.id.clone(),
@@ -1103,6 +1265,7 @@ fn provider_unit(
text_source_kind: unit.text_source_kind.clone(),
asset_name: unit.asset_name.clone(),
context: unit.context.clone(),
glossary_constraints,
}
}
@@ -1110,6 +1273,7 @@ fn validate_provider_response(
request: &TranslationProviderRequest,
response: TranslationProviderResponse,
provider_name: &str,
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
) -> anyhow::Result<Vec<TranslationTaskUnitResult>> {
if response.provider_run_id != request.provider_run_id {
return Err(anyhow::anyhow!(
@@ -1150,6 +1314,9 @@ fn validate_provider_response(
result.unit_id
));
}
let glossary_qa = glossary_evaluations
.get(&result.unit_id)
.map(|evaluation| evaluation.check_translation(&result.translated_text));
results.push(TranslationTaskUnitResult {
unit_id: result.unit_id,
source_text: result.source_text,
@@ -1159,6 +1326,8 @@ fn validate_provider_response(
provider: provider_name.to_string(),
provider_run_id: request.provider_run_id.clone(),
translated_unix_seconds: unix_seconds_now(),
glossary_qa,
glossary_override: None,
});
}
if seen.len() != expected.len() {
@@ -1175,6 +1344,7 @@ fn translation_memory_result(
task: &PersistedTranslationTask,
unit: &OfficialTextUnitIndexUnit,
entry: &bat_core::domain::TranslationMemoryEntry,
glossary_qa: GlossaryQaReport,
) -> TranslationTaskUnitResult {
TranslationTaskUnitResult {
unit_id: unit.id.clone(),
@@ -1185,6 +1355,8 @@ fn translation_memory_result(
provider: "translation_memory".to_string(),
provider_run_id: task.provider_run_id.clone().unwrap_or_default(),
translated_unix_seconds: unix_seconds_now(),
glossary_qa: Some(glossary_qa),
glossary_override: None,
}
}
@@ -1222,6 +1394,16 @@ fn record_translation_memory_failure(
Ok(())
}
fn record_glossary_failure(context: &WorkerTaskContext<'_>, message: String) -> anyhow::Result<()> {
context
.stats
.glossary_failures
.lock()
.map_err(|_| anyhow::anyhow!("写入 Glossary 诊断时 mutex poisoned"))?
.push(message);
Ok(())
}
async fn record_provider_failure(
context: &WorkerTaskContext<'_>,
task: &PersistedTranslationTask,
@@ -1455,6 +1637,131 @@ mod tests {
assert_eq!(task.translation_results[0].translated_text, "translated-0");
}
#[tokio::test]
async fn worker_sends_approved_glossary_constraints_and_persists_qa() {
let (temp, queue) = fixture_root();
let textunit_index = index(temp.path());
crate::official_textunit_queue::write_textunit_task_queue_at(temp.path(), &queue).unwrap();
crate::official_parse::write_textunit_index_at(temp.path(), &textunit_index).unwrap();
let glossary_path = temp.path().join("glossary.sqlite");
let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap();
glossary
.add(bat_core::domain::GlossaryTermDraft {
term_id: "term-source-0".to_string(),
definition: bat_core::domain::GlossaryTermSnapshot {
source_term: "source-0".to_string(),
aliases: Vec::new(),
recommended_translation: "term-0".to_string(),
allowed_translations: Vec::new(),
source_language: Some("en".to_string()),
target_language: Some("zh-Hans".to_string()),
category: Some("test".to_string()),
priority: 10,
scope: BTreeMap::new(),
},
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
source: bat_core::domain::GlossarySourceRecord {
source_kind: bat_core::domain::GlossarySourceKind::Manual,
source_ref: Some("worker-test".to_string()),
source_author: Some("test".to_string()),
source_note: None,
observed_unix_seconds: 1,
},
})
.await
.unwrap();
glossary
.review(
"term-source-0",
bat_core::domain::GlossaryReviewStatus::Approved,
"reviewer",
Some("test approval".to_string()),
)
.await
.unwrap();
#[derive(Debug)]
struct GlossaryProvider {
requests: Arc<Mutex<Vec<TranslationProviderRequest>>>,
}
#[async_trait::async_trait]
impl TranslationProvider for GlossaryProvider {
fn name(&self) -> &'static str {
"glossary-test"
}
async fn translate(
&self,
request: TranslationProviderRequest,
) -> Result<TranslationProviderResponse, TranslationProviderError> {
self.requests.lock().unwrap().push(request.clone());
Ok(TranslationProviderResponse {
provider_run_id: request.provider_run_id,
units: request
.units
.into_iter()
.map(|unit| TranslationProviderUnitResult {
unit_id: unit.unit_id,
source_text: unit.source_text.clone(),
translated_text: if unit.source_text == "source-0" {
"term-0".to_string()
} else {
"translated-1".to_string()
},
})
.collect(),
})
}
}
let requests = Arc::new(Mutex::new(Vec::new()));
let config = TranslationWorkerConfig {
glossary_path: Some(glossary_path),
concurrency: 1,
retry_backoff: Duration::ZERO,
..TranslationWorkerConfig::default()
};
let report = run_translation_worker_with_provider(
temp.path(),
&config,
Arc::new(GlossaryProvider {
requests: Arc::clone(&requests),
}),
)
.await
.unwrap();
assert_eq!(report.completed_count, 1);
assert!(report.glossary_available);
assert_eq!(report.glossary_blocked_count, 0);
{
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].units[0].glossary_constraints.len(), 1);
assert_eq!(
requests[0].units[0].glossary_constraints[0].term_id,
"term-source-0"
);
}
let repository = SqliteTranslationTaskRepository::open(
SqliteTranslationTaskRepository::repository_path(temp.path()),
)
.await
.unwrap();
let task = repository.find(&queue.tasks[0].task_id).await.unwrap();
assert_eq!(task.translation_results[0].translated_text, "term-0");
assert_eq!(
task.translation_results[0]
.glossary_qa
.as_ref()
.map(|qa| qa.status),
Some(bat_core::domain::GlossaryQaStatus::Pass)
);
}
#[tokio::test]
async fn worker_honors_cancellation_before_claiming_tasks() {
let (temp, queue) = fixture_root();
+167
View File
@@ -1,5 +1,6 @@
//! Manual translation workbench and controlled UnityFS repack workflows.
use crate::glossary::SqliteGlossaryRepository;
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
use crate::path_security::{
@@ -15,6 +16,7 @@ use bat_assetbundle::{
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
};
use bat_core::domain::{GlossaryOverride, GlossaryQaReport};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
@@ -90,6 +92,12 @@ pub struct TranslationWorkbenchEntry {
/// Extraction source kind such as TextAsset or TypeTreeField.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_source_kind: Option<String>,
/// Deterministic Glossary QA for the current translation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<GlossaryQaReport>,
/// Explicit human confirmation for a blocking Glossary deviation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<GlossaryOverride>,
}
/// Summary produced by `i18n validate`.
@@ -274,6 +282,73 @@ pub fn set_translation(
.find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = Some(translated_text);
entry.glossary_qa = None;
entry.glossary_override = None;
let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?;
Ok(updated)
}
/// Updates one translation and evaluates the project Glossary.
pub fn set_translation_checked(
resource_root: &Path,
workbench_path: &Path,
entry_id: &str,
translated_text: String,
glossary_override: Option<GlossaryOverride>,
) -> anyhow::Result<TranslationWorkbenchEntry> {
set_translation_checked_with_glossary_path(
resource_root,
workbench_path,
entry_id,
translated_text,
glossary_override,
None,
)
}
/// Updates one translation using an optional configured Glossary path.
pub fn set_translation_checked_with_glossary_path(
resource_root: &Path,
workbench_path: &Path,
entry_id: &str,
translated_text: String,
glossary_override: Option<GlossaryOverride>,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<TranslationWorkbenchEntry> {
let mut workbench = read_translation_workbench(workbench_path)?;
let current = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?
.units
.into_iter()
.find(|unit| unit.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("当前 release 不存在 TextUnit{entry_id}"))?;
let glossary_path = configured_glossary_path
.map(PathBuf::from)
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
let glossary = open_glossary_if_present(&glossary_path)?;
let qa = glossary
.as_ref()
.map(|glossary| evaluate_glossary_entry(glossary, &current, &translated_text))
.transpose()?;
if let Some(qa) = qa.as_ref().filter(|qa| qa.status.is_blocked()) {
validate_glossary_override(glossary_override.as_ref())?;
let _ = qa;
} else if glossary_override.is_some() {
return Err(anyhow::anyhow!(
"Glossary override 只能用于存在 blocking QA 的译文"
));
}
let entry = workbench
.entries
.iter_mut()
.find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = Some(translated_text);
entry.glossary_qa = qa;
entry.glossary_override = glossary_override;
let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?;
@@ -305,6 +380,8 @@ pub fn unset_translation(
.find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = None;
entry.glossary_qa = None;
entry.glossary_override = None;
let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?;
@@ -320,6 +397,21 @@ pub fn validate_translation_workbench(
resource_root: &Path,
official_release_id: &str,
workbench: &TranslationWorkbench,
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
validate_translation_workbench_with_glossary_path(
resource_root,
official_release_id,
workbench,
None,
)
}
/// Validates a workbench using an optional configured Glossary path.
pub fn validate_translation_workbench_with_glossary_path(
resource_root: &Path,
official_release_id: &str,
workbench: &TranslationWorkbench,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?;
if workbench.official_release_id != official_release_id {
@@ -349,6 +441,10 @@ pub fn validate_translation_workbench(
let mut changed_entries = 0;
let mut publishable_entries = 0;
let mut repack_entries = 0;
let glossary_path = configured_glossary_path
.map(PathBuf::from)
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
let glossary = open_glossary_if_present(&glossary_path)?;
for entry in &workbench.entries {
if !seen_ids.insert(entry.id.as_str()) {
@@ -366,6 +462,12 @@ pub fn validate_translation_workbench(
unchanged_entries += 1;
continue;
}
if let Some(glossary) = glossary.as_ref() {
let qa = evaluate_glossary_entry(glossary, current, translated_text)?;
if qa.status.is_blocked() {
validate_glossary_override(entry.glossary_override.as_ref())?;
}
}
changed_entries += 1;
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
let is_publishable = entry.archive_entry.is_none()
@@ -419,6 +521,63 @@ pub fn validate_translation_workbench(
})
}
fn open_glossary_if_present(path: &Path) -> anyhow::Result<Option<SqliteGlossaryRepository>> {
if std::fs::symlink_metadata(path).is_err() {
return Ok(None);
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime
.block_on(SqliteGlossaryRepository::open(path))
.map(Some)
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))
}
fn evaluate_glossary_entry(
glossary: &SqliteGlossaryRepository,
unit: &OfficialTextUnitIndexUnit,
translated_text: &str,
) -> anyhow::Result<GlossaryQaReport> {
let context = crate::translation_memory::translation_memory_context(
&unit.destination,
unit.archive_entry.as_deref(),
unit.serialized_file.as_deref(),
unit.path_id,
unit.class_id,
unit.field_path.as_deref(),
unit.format.as_deref(),
unit.asset_name.as_deref(),
unit.text_source_kind.as_deref(),
&unit.context,
);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime
.block_on(glossary.diagnose(&unit.source_text, &context))
.map(|evaluation| evaluation.check_translation(translated_text))
.map_err(|error| anyhow::anyhow!("执行 Glossary QA 失败:{error}"))
}
fn validate_glossary_override(glossary_override: Option<&GlossaryOverride>) -> anyhow::Result<()> {
let Some(glossary_override) = glossary_override else {
return Err(anyhow::anyhow!(
"Glossary QA blocked;需要 reviewer、reason 和 provenance 显式确认"
));
};
if glossary_override.reviewer.trim().is_empty()
|| glossary_override.reason.trim().is_empty()
|| glossary_override.provenance.trim().is_empty()
|| glossary_override.confirmed_unix_seconds == 0
{
return Err(anyhow::anyhow!(
"Glossary override 的 reviewer、reason、provenance 和 confirmed_unix_seconds 必须有效"
));
}
Ok(())
}
/// Converts reviewed entries to localized patch operations supported by the
/// current UnityFS write layer.
///
@@ -628,6 +787,8 @@ fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatch
.review_status
.clone()
.unwrap_or_else(|| "manual_reviewed".to_string()),
glossary_qa: entry.glossary_qa.clone(),
glossary_override: entry.glossary_override.clone(),
}
}
@@ -911,6 +1072,8 @@ fn workbench_entry_from_worker_result(
entry.translation_source_kind = Some(result.source_kind.as_str().to_string());
entry.translation_memory_record_id = result.translation_memory_record_id.clone();
entry.translated_unix_seconds = Some(result.translated_unix_seconds);
entry.glossary_qa = result.glossary_qa.clone();
entry.glossary_override = result.glossary_override.clone();
entry.review_status = Some(
match result.source_kind {
TranslationTaskResultSourceKind::Provider => "provider_completed",
@@ -964,6 +1127,8 @@ impl TranslationWorkbenchEntry {
review_status: None,
format: unit.format.clone(),
text_source_kind: unit.text_source_kind.clone(),
glossary_qa: None,
glossary_override: None,
}
}
}
@@ -1003,6 +1168,8 @@ mod tests {
review_status: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
glossary_qa: None,
glossary_override: None,
}],
}
}
+232
View File
@@ -56,6 +56,9 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/translation/handoff",
"/admin/translation/memory/summary",
"/admin/translation/memory/query",
"/admin/translation/glossary/summary",
"/admin/translation/glossary/query",
"/admin/translation/glossary/diagnose",
"/admin/translation/status",
},
Controls: []string{
@@ -75,6 +78,11 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/translation-worker-run",
"/admin/control/translation-proofread",
"/admin/control/translation-memory-confirm",
"/admin/control/translation-glossary-add",
"/admin/control/translation-glossary-update",
"/admin/control/translation-glossary-approve",
"/admin/control/translation-glossary-deprecate",
"/admin/control/translation-glossary-delete",
"/admin/control/localized-publish",
"/admin/control/localized-rollback",
},
@@ -124,6 +132,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminTranslationMemoryConfirm(w, r)
return
}
if strings.HasPrefix(action, "translation-glossary-") {
s.handleAdminGlossaryControl(w, r, action)
return
}
if action == "localized-publish" {
s.handleAdminLocalizedPublish(w, r)
return
@@ -309,6 +321,226 @@ func (s *Server) handleAdminTranslationMemoryConfirm(w http.ResponseWriter, r *h
})
}
func (s *Server) handleAdminGlossarySummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
params := backendrpc.GlossarySummaryParams{
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
}
result, err := backend.GlossarySummary(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-glossary-summary", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminGlossaryQuery(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
params := backendrpc.GlossaryQueryParams{
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"),
Category: firstTrimmedQuery(r.URL.Query(), "category"),
ReviewStatus: firstTrimmedQuery(r.URL.Query(), "review_status"),
}
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
limit, err := strconv.ParseUint(raw, 10, 64)
if err != nil || limit == 0 || limit > 1000 {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "limit must be in 1..=1000")
return
}
params.Limit = &limit
}
result, err := backend.GlossaryQuery(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-glossary-query", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminGlossaryDiagnose(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
params := backendrpc.GlossaryDiagnoseParams{
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"),
}
if params.SourceText == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "source_text is required")
return
}
if raw := firstTrimmedQuery(r.URL.Query(), "context", "source_context"); raw != "" {
if err := json.Unmarshal([]byte(raw), &params.Context); err != nil || params.Context == nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "context must be a JSON object with string values")
return
}
}
result, err := backend.GlossaryDiagnose(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-glossary-diagnose", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminGlossaryControl(w http.ResponseWriter, r *http.Request, action string) {
backend, ok := s.backend.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
switch action {
case "translation-glossary-add", "translation-glossary-update":
var params backendrpc.GlossaryTermMutationParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.SourceTerm) == "" ||
strings.TrimSpace(params.RecommendedTranslation) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, source_term and recommended_translation are required")
return
}
var result *backendrpc.GlossaryMutationReport
var err error
if action == "translation-glossary-add" {
result, err = backend.GlossaryAdd(r.Context(), params)
} else {
if strings.TrimSpace(params.Reviewer) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "reviewer is required for update")
return
}
result, err = backend.GlossaryUpdate(r.Context(), params)
}
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: glossaryControlRPCMethod(action),
Status: "accepted",
Result: result,
})
case "translation-glossary-approve", "translation-glossary-deprecate":
var params backendrpc.GlossaryReviewParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.Reviewer) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id and reviewer are required")
return
}
var result *backendrpc.GlossaryMutationReport
var err error
if action == "translation-glossary-approve" {
result, err = backend.GlossaryApprove(r.Context(), params)
} else {
result, err = backend.GlossaryDeprecate(r.Context(), params)
}
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: glossaryControlRPCMethod(action),
Status: "accepted",
Result: result,
})
case "translation-glossary-delete":
var params backendrpc.GlossaryDeleteParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" ||
strings.TrimSpace(params.Reviewer) == "" ||
strings.TrimSpace(params.Reason) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, reviewer and reason are required for delete")
return
}
result, err := backend.GlossaryDelete(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: glossaryControlRPCMethod(action),
Status: "accepted",
Result: result,
})
default:
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown glossary control action")
}
}
func glossaryControlRPCMethod(action string) string {
switch action {
case "translation-glossary-add":
return "translation.glossary.add"
case "translation-glossary-update":
return "translation.glossary.update"
case "translation-glossary-approve":
return "translation.glossary.approve"
case "translation-glossary-deprecate":
return "translation.glossary.deprecate"
case "translation-glossary-delete":
return "translation.glossary.delete"
default:
return ""
}
}
func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(LocalizedBackend)
if !ok || backend == nil {
+148 -2
View File
@@ -637,6 +637,12 @@ type controlBackend struct {
translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams
translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams
translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams
glossarySummaryParams []backendrpc.GlossarySummaryParams
glossaryQueryParams []backendrpc.GlossaryQueryParams
glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams
glossaryMutationParams []backendrpc.GlossaryTermMutationParams
glossaryReviewParams []backendrpc.GlossaryReviewParams
glossaryDeleteParams []backendrpc.GlossaryDeleteParams
localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams
}
@@ -825,6 +831,87 @@ func (b *controlBackend) TranslationMemoryConfirm(ctx context.Context, params ba
}, nil
}
func (b *controlBackend) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
b.calls = append(b.calls, "translation.glossary.summary")
b.glossarySummaryParams = append(b.glossarySummaryParams, params)
schemaVersion := uint64(1)
return &backendrpc.GlossarySummaryReport{
Available: true,
Path: params.GlossaryPath,
SchemaVersion: &schemaVersion,
Summary: &backendrpc.GlossarySummary{
SchemaVersion: schemaVersion,
TermCount: 2,
ApprovedCount: 1,
DraftCount: 1,
},
}, nil
}
func (b *controlBackend) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) {
b.calls = append(b.calls, "translation.glossary.query")
b.glossaryQueryParams = append(b.glossaryQueryParams, params)
return &backendrpc.GlossaryQueryReport{
Available: true,
Path: params.GlossaryPath,
Terms: []backendrpc.GlossaryTerm{{
TermID: "term-sensei",
GlossaryTermSnapshot: backendrpc.GlossaryTermSnapshot{
SourceTerm: "Sensei",
RecommendedTranslation: "老师",
Priority: 10,
},
ReviewStatus: backendrpc.GlossaryStatusApproved,
Source: backendrpc.GlossarySourceRecord{
SourceKind: "manual",
ObservedUnixSeconds: 100,
},
}},
}, nil
}
func (b *controlBackend) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) {
b.calls = append(b.calls, "translation.glossary.diagnose")
b.glossaryDiagnoseParams = append(b.glossaryDiagnoseParams, params)
return &backendrpc.GlossaryDiagnoseReport{
Available: true,
Path: params.GlossaryPath,
SourceText: params.SourceText,
Context: params.Context,
Evaluation: json.RawMessage(`{"constraints":[],"diagnostics":[],"blocked":false}`),
}, nil
}
func (b *controlBackend) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.add")
b.glossaryMutationParams = append(b.glossaryMutationParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.update")
b.glossaryMutationParams = append(b.glossaryMutationParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.approve")
b.glossaryReviewParams = append(b.glossaryReviewParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.deprecate")
b.glossaryReviewParams = append(b.glossaryReviewParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.delete")
b.glossaryDeleteParams = append(b.glossaryDeleteParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "localized.status")
return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil
@@ -1044,6 +1131,48 @@ func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T)
t.Fatalf("missing TM query source status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/summary?glossary_path=%2Fvar%2Flib%2Fbat%2Fglossary.sqlite", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
!strings.Contains(recorder.Body.String(), `"approved_count":1`) ||
len(backend.glossarySummaryParams) != 1 ||
backend.glossarySummaryParams[0].GlossaryPath != "/var/lib/bat/glossary.sqlite" {
t.Fatalf("Glossary summary status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossarySummaryParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/query?source_text=Sensei&limit=20", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
len(backend.glossaryQueryParams) != 1 ||
backend.glossaryQueryParams[0].SourceText != "Sensei" ||
backend.glossaryQueryParams[0].Limit == nil ||
*backend.glossaryQueryParams[0].Limit != 20 {
t.Fatalf("Glossary query status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryQueryParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose?source_text=Sensei&context=%7B%22destination%22%3A%22Bundle%2Fdialogue.bundle%22%7D", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK ||
len(backend.glossaryDiagnoseParams) != 1 ||
backend.glossaryDiagnoseParams[0].SourceText != "Sensei" ||
backend.glossaryDiagnoseParams[0].Context["destination"] != "Bundle/dialogue.bundle" {
t.Fatalf("Glossary diagnose status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryDiagnoseParams)
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("missing Glossary diagnose source status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks?limit=0", nil)
request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder()
@@ -1586,6 +1715,7 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
!strings.Contains(rr.Body.String(), "/admin/tasks") ||
!strings.Contains(rr.Body.String(), "/admin/parse/text-units") ||
!strings.Contains(rr.Body.String(), "translation_results") ||
!strings.Contains(rr.Body.String(), "glossary_override") ||
!strings.Contains(rr.Body.String(), "task-cancel") ||
!strings.Contains(rr.Body.String(), "/admin/translation/memory/query") ||
!strings.Contains(rr.Body.String(), "translation-memory-confirm") {
@@ -1814,10 +1944,15 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
{name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文","glossary_override":{"reviewer":"reviewer","reason":"approved deviation","provenance":"manual-review","confirmed_unix_seconds":100}}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
{name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"},
{name: "translation glossary add", action: "translation-glossary-add", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.add", call: "translation.glossary.add"},
{name: "translation glossary update", action: "translation-glossary-update", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","reviewer":"reviewer","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.update", call: "translation.glossary.update"},
{name: "translation glossary approve", action: "translation-glossary-approve", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"approved"}`, rpcMethod: "translation.glossary.approve", call: "translation.glossary.approve"},
{name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"},
{name: "translation glossary delete", action: "translation-glossary-delete", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"duplicate"}`, rpcMethod: "translation.glossary.delete", call: "translation.glossary.delete"},
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
{name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
}
@@ -1846,7 +1981,9 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
if len(backend.translationTaskUpdates) != 1 ||
backend.translationTaskUpdates[0].Provider != "manual" ||
len(backend.translationTaskUpdates[0].TranslationResults) != 1 ||
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" {
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride == nil ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.Reviewer != "reviewer" {
t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates)
}
if len(backend.translationMemoryConfirmParams) != 1 ||
@@ -1854,6 +1991,15 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" {
t.Fatalf("TM confirm params=%#v", backend.translationMemoryConfirmParams)
}
if len(backend.glossaryMutationParams) != 2 ||
backend.glossaryMutationParams[0].TermID != "term-sensei" ||
backend.glossaryMutationParams[1].Reviewer != "reviewer" ||
len(backend.glossaryReviewParams) != 2 ||
backend.glossaryReviewParams[0].TermID != "term-sensei" ||
len(backend.glossaryDeleteParams) != 1 ||
backend.glossaryDeleteParams[0].Reason != "duplicate" {
t.Fatalf("Glossary params mutation=%#v review=%#v delete=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams, backend.glossaryDeleteParams)
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
request.Header.Set("Authorization", "Bearer control-token")
+41
View File
@@ -198,3 +198,44 @@ func TestTranslationMemoryRustContractMirror(t *testing.T) {
t.Fatalf("missing TM summary=%+v", missing)
}
}
func TestGlossaryRustContractMirror(t *testing.T) {
raw := readContractFixture(t, "glossary-query.json")
if bytes.Contains(raw, []byte("/tmp/")) {
t.Fatal("Glossary mirror contains a local temporary path")
}
var report backendrpc.GlossaryQueryReport
if err := json.Unmarshal(raw, &report); err != nil {
t.Fatalf("decode Glossary query mirror: %v", err)
}
if !report.Available || report.Path != "${GLOSSARY_PATH}" ||
report.SourceText != "${SOURCE_TEXT}" || len(report.Terms) != 1 {
t.Fatalf("Glossary report=%+v", report)
}
term := report.Terms[0]
if term.TermID != "${TERM_ID}" ||
term.SourceTerm != "${SOURCE_TERM}" ||
term.RecommendedTranslation != "${RECOMMENDED_TRANSLATION}" ||
term.ReviewStatus != backendrpc.GlossaryStatusApproved ||
term.Source.SourceKind != "manual" ||
term.Source.ObservedUnixSeconds != 100 ||
len(term.History) != 2 ||
term.History[0].ReviewStatus != backendrpc.GlossaryStatusDraft ||
term.History[1].Action != "approved" ||
term.History[1].ReviewStatus != backendrpc.GlossaryStatusApproved ||
term.History[1].Snapshot.Priority != 10 {
t.Fatalf("Glossary term=%+v", term)
}
var missing backendrpc.GlossarySummaryReport
if err := json.Unmarshal([]byte(`{
"available": false,
"path": "${GLOSSARY_PATH}",
"reason": "database_missing"
}`), &missing); err != nil {
t.Fatalf("decode missing Glossary summary mirror: %v", err)
}
if missing.Available || missing.Summary != nil || missing.SchemaVersion != nil ||
missing.Reason != "database_missing" {
t.Fatalf("missing Glossary summary=%+v", missing)
}
}
+145 -1
View File
@@ -461,6 +461,86 @@ paths:
description: Missing or invalid admin token.
"503":
description: Rust bat Translation Memory backend is unavailable.
/admin/translation/glossary/summary:
get:
summary: Read Rust-owned Glossary summary
parameters:
- name: glossary_path
in: query
schema:
type: string
responses:
"200":
description: Glossary availability and review-state counts.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Glossary backend is unavailable.
/admin/translation/glossary/query:
get:
summary: Query Rust-owned Glossary terms
parameters:
- name: source_text
in: query
schema:
type: string
- name: category
in: query
schema:
type: string
- name: review_status
in: query
schema:
type: string
enum: [draft, approved, deprecated, rejected]
- name: limit
in: query
schema:
type: integer
format: int64
minimum: 1
maximum: 1000
default: 100
- name: glossary_path
in: query
schema:
type: string
responses:
"200":
description: Glossary terms with source and review history.
"400":
description: Invalid Glossary query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Glossary backend is unavailable.
/admin/translation/glossary/diagnose:
get:
summary: Run deterministic Glossary diagnostics
parameters:
- name: source_text
in: query
required: true
schema:
type: string
- name: context
in: query
description: JSON object whose values are strings.
schema:
type: string
- name: glossary_path
in: query
schema:
type: string
responses:
"200":
description: Glossary constraints, diagnostics, and blocked decision.
"400":
description: Missing source text or invalid context.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat Glossary backend is unavailable.
/admin/translation/status:
get:
summary: Read Rust-owned localized release status
@@ -480,7 +560,7 @@ paths:
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, localized-publish, localized-rollback]
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback]
requestBody:
required: false
content:
@@ -546,6 +626,21 @@ paths:
type: string
translated_text:
type: string
glossary_override:
type: object
required: [reviewer, reason, provenance, confirmed_unix_seconds]
additionalProperties: false
properties:
reviewer:
type: string
reason:
type: string
provenance:
type: string
confirmed_unix_seconds:
type: integer
format: int64
minimum: 1
fixture_path:
type: string
concurrency:
@@ -573,8 +668,57 @@ paths:
type: string
translation_memory_path:
type: string
glossary_path:
type: string
record_id:
type: string
term_id:
type: string
source_term:
type: string
aliases:
type: array
items:
type: string
recommended_translation:
type: string
allowed_translations:
type: array
items:
type: string
source_language:
type: string
target_language:
type: string
category:
type: string
priority:
type: integer
format: int64
scope:
type: object
additionalProperties:
type: string
review_status:
type: string
enum: [draft, approved, deprecated, rejected]
source:
type: object
additionalProperties: false
required: [source_kind, observed_unix_seconds]
properties:
source_kind:
type: string
enum: [manual, imported]
source_ref:
type: string
source_author:
type: string
source_note:
type: string
observed_unix_seconds:
type: integer
format: int64
reviewer:
type: string
reason:
+38
View File
@@ -92,6 +92,19 @@ type TranslationMemoryBackend interface {
TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error)
}
// GlossaryBackend exposes Rust-owned Glossary management and diagnostics.
// Go forwards these typed calls and never opens the Glossary database.
type GlossaryBackend interface {
GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error)
GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error)
GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error)
GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error)
}
// LocalizedBackend exposes localized release status and the explicit
// publish/rollback controls used by the authenticated dashboard.
type LocalizedBackend interface {
@@ -203,6 +216,31 @@ func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendr
return r.Client.TranslationMemoryConfirm(ctx, params)
}
func (r RPCClient) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
return r.Client.GlossarySummary(ctx, params)
}
func (r RPCClient) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) {
return r.Client.GlossaryQuery(ctx, params)
}
func (r RPCClient) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) {
return r.Client.GlossaryDiagnose(ctx, params)
}
func (r RPCClient) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryAdd(ctx, params)
}
func (r RPCClient) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryUpdate(ctx, params)
}
func (r RPCClient) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryApprove(ctx, params)
}
func (r RPCClient) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryDeprecate(ctx, params)
}
func (r RPCClient) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryDelete(ctx, params)
}
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.LocalizedStatus(ctx)
}
+6
View File
@@ -76,6 +76,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary)
mux.HandleFunc("/admin/translation/memory/query", s.handleAdminTranslationMemoryQuery)
mux.HandleFunc("/admin/translation/glossary/summary", s.handleAdminGlossarySummary)
mux.HandleFunc("/admin/translation/glossary/query", s.handleAdminGlossaryQuery)
mux.HandleFunc("/admin/translation/glossary/diagnose", s.handleAdminGlossaryDiagnose)
mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus)
mux.HandleFunc("/admin/control/", s.handleAdminControl)
mux.HandleFunc("/admin/", s.handleAdminIndex)
@@ -155,6 +158,9 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/admin/translation/handoff",
"/admin/translation/memory/summary",
"/admin/translation/memory/query",
"/admin/translation/glossary/summary",
"/admin/translation/glossary/query",
"/admin/translation/glossary/diagnose",
"/admin/translation/status",
"/admin/control/{action}",
},
+1
View File
@@ -10,6 +10,7 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本
- `resource.manifest` 第一页分页响应。
- 对应 release 的 `official-sync-snapshot.json`
- `launcher_metadata``game_main_config_bootstrap` 的 Go mirror 解码。
- Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live
daemon socket 或完整发布切换验证。
+99
View File
@@ -0,0 +1,99 @@
{
"available": true,
"path": "${GLOSSARY_PATH}",
"source_text": "${SOURCE_TEXT}",
"terms": [
{
"term_id": "${TERM_ID}",
"source_term": "${SOURCE_TERM}",
"aliases": [
"${ALIAS}"
],
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
"allowed_translations": [
"${ALLOWED_TRANSLATION}"
],
"source_language": "en",
"target_language": "zh-Hans",
"category": "character",
"priority": 10,
"scope": {
"destination": "${DESTINATION}"
},
"review_status": "approved",
"source": {
"source_kind": "manual",
"source_ref": "${SOURCE_REF}",
"source_author": "${SOURCE_AUTHOR}",
"source_note": "${SOURCE_NOTE}",
"observed_unix_seconds": 100
},
"history": [
{
"history_id": "${HISTORY_CREATED_ID}",
"action": "created",
"source": {
"source_kind": "manual",
"source_ref": "${SOURCE_REF}",
"source_author": "${SOURCE_AUTHOR}",
"source_note": "${SOURCE_NOTE}",
"observed_unix_seconds": 100
},
"review_status": "draft",
"snapshot": {
"source_term": "${SOURCE_TERM}",
"aliases": [
"${ALIAS}"
],
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
"allowed_translations": [
"${ALLOWED_TRANSLATION}"
],
"source_language": "en",
"target_language": "zh-Hans",
"category": "character",
"priority": 10,
"scope": {
"destination": "${DESTINATION}"
}
},
"observed_unix_seconds": 100
},
{
"history_id": "${HISTORY_APPROVED_ID}",
"action": "approved",
"reviewer": "${REVIEWER}",
"reason": "reviewed",
"source": {
"source_kind": "manual",
"source_ref": "${SOURCE_REF}",
"source_author": "${SOURCE_AUTHOR}",
"source_note": "${SOURCE_NOTE}",
"observed_unix_seconds": 100
},
"review_status": "approved",
"snapshot": {
"source_term": "${SOURCE_TERM}",
"aliases": [
"${ALIAS}"
],
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
"allowed_translations": [
"${ALLOWED_TRANSLATION}"
],
"source_language": "en",
"target_language": "zh-Hans",
"category": "character",
"priority": 10,
"scope": {
"destination": "${DESTINATION}"
}
},
"observed_unix_seconds": 101
}
],
"created_unix_seconds": 100,
"updated_unix_seconds": 101
}
]
}
+210 -3
View File
@@ -267,9 +267,10 @@ type ScheduleRunParams struct {
// TranslationTaskUnitResultParam is the dashboard/manual-review subset of one
// TextUnit result accepted by Rust translation.task.update.
type TranslationTaskUnitResultParam struct {
UnitID string `json:"unit_id"`
SourceText string `json:"source_text"`
TranslatedText string `json:"translated_text"`
UnitID string `json:"unit_id"`
SourceText string `json:"source_text"`
TranslatedText string `json:"translated_text"`
GlossaryOverride *GlossaryOverride `json:"glossary_override,omitempty"`
}
// TranslationTaskUpdateParams is used by translation.task.update to persist
@@ -310,6 +311,7 @@ type TranslationWorkerRunParams struct {
Provider string `json:"provider,omitempty"`
FixturePath string `json:"fixture_path,omitempty"`
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
GlossaryPath string `json:"glossary_path,omitempty"`
Concurrency *uint64 `json:"concurrency,omitempty"`
MaxAttempts *uint64 `json:"max_attempts,omitempty"`
LeaseSeconds *uint64 `json:"lease_seconds,omitempty"`
@@ -323,6 +325,7 @@ type TranslationWorkerConfig struct {
Provider string `json:"provider"`
FixturePath string `json:"fixture_path,omitempty"`
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
GlossaryPath string `json:"glossary_path,omitempty"`
Concurrency uint64 `json:"concurrency"`
MaxAttempts uint64 `json:"max_attempts"`
LeaseSeconds uint64 `json:"lease_seconds"`
@@ -476,6 +479,162 @@ type TranslationMemoryConfirmReport struct {
Entry TranslationMemoryEntry `json:"entry"`
}
// GlossaryReviewStatus is the Rust-owned term review state.
type GlossaryReviewStatus string
const (
GlossaryStatusDraft GlossaryReviewStatus = "draft"
GlossaryStatusApproved GlossaryReviewStatus = "approved"
GlossaryStatusDeprecated GlossaryReviewStatus = "deprecated"
GlossaryStatusRejected GlossaryReviewStatus = "rejected"
)
// GlossarySourceRecord identifies the source/provenance of a term.
type GlossarySourceRecord struct {
SourceKind string `json:"source_kind"`
SourceRef *string `json:"source_ref,omitempty"`
SourceAuthor *string `json:"source_author,omitempty"`
SourceNote *string `json:"source_note,omitempty"`
ObservedUnixSeconds uint64 `json:"observed_unix_seconds"`
}
// GlossaryTermSnapshot is the versioned definition shared by Rust and Go.
type GlossaryTermSnapshot struct {
SourceTerm string `json:"source_term"`
Aliases []string `json:"aliases,omitempty"`
RecommendedTranslation string `json:"recommended_translation"`
AllowedTranslations []string `json:"allowed_translations,omitempty"`
SourceLanguage *string `json:"source_language,omitempty"`
TargetLanguage *string `json:"target_language,omitempty"`
Category *string `json:"category,omitempty"`
Priority int `json:"priority"`
Scope map[string]string `json:"scope,omitempty"`
}
// GlossaryOverride records explicit human approval for a deviation.
type GlossaryOverride struct {
Reviewer string `json:"reviewer"`
Reason string `json:"reason"`
Provenance string `json:"provenance"`
ConfirmedUnixSeconds uint64 `json:"confirmed_unix_seconds"`
}
// GlossaryTerm mirrors a persisted Rust term and its source history.
type GlossaryTerm struct {
TermID string `json:"term_id"`
GlossaryTermSnapshot
ReviewStatus GlossaryReviewStatus `json:"review_status"`
Source GlossarySourceRecord `json:"source"`
History []GlossaryHistoryRecord `json:"history,omitempty"`
CreatedUnixSeconds uint64 `json:"created_unix_seconds"`
UpdatedUnixSeconds uint64 `json:"updated_unix_seconds"`
}
// GlossaryHistoryRecord is one durable term mutation.
type GlossaryHistoryRecord struct {
HistoryID string `json:"history_id"`
Action string `json:"action"`
Reviewer *string `json:"reviewer,omitempty"`
Reason *string `json:"reason,omitempty"`
Source GlossarySourceRecord `json:"source"`
ReviewStatus GlossaryReviewStatus `json:"review_status"`
Snapshot GlossaryTermSnapshot `json:"snapshot"`
ObservedUnixSeconds uint64 `json:"observed_unix_seconds"`
}
// GlossarySummary mirrors translation.glossary.summary.
type GlossarySummary struct {
SchemaVersion uint64 `json:"schema_version"`
TermCount uint64 `json:"term_count"`
ApprovedCount uint64 `json:"approved_count"`
DraftCount uint64 `json:"draft_count"`
DeprecatedCount uint64 `json:"deprecated_count"`
RejectedCount uint64 `json:"rejected_count"`
}
type GlossarySummaryParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
}
type GlossaryQueryParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
SourceText string `json:"source_text,omitempty"`
Category string `json:"category,omitempty"`
ReviewStatus string `json:"review_status,omitempty"`
Limit *uint64 `json:"limit,omitempty"`
}
type GlossaryDiagnoseParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
SourceText string `json:"source_text"`
Context map[string]string `json:"context,omitempty"`
}
type GlossaryTermMutationParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
TermID string `json:"term_id"`
SourceTerm string `json:"source_term"`
Aliases []string `json:"aliases,omitempty"`
RecommendedTranslation string `json:"recommended_translation"`
AllowedTranslations []string `json:"allowed_translations,omitempty"`
SourceLanguage *string `json:"source_language,omitempty"`
TargetLanguage *string `json:"target_language,omitempty"`
Category *string `json:"category,omitempty"`
Priority int `json:"priority"`
Scope map[string]string `json:"scope,omitempty"`
ReviewStatus string `json:"review_status"`
Source GlossarySourceRecord `json:"source"`
Reviewer string `json:"reviewer,omitempty"`
Reason string `json:"reason,omitempty"`
}
type GlossaryReviewParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
TermID string `json:"term_id"`
Reviewer string `json:"reviewer"`
Reason string `json:"reason,omitempty"`
}
type GlossaryDeleteParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
TermID string `json:"term_id"`
Reviewer string `json:"reviewer"`
Reason string `json:"reason"`
}
type GlossarySummaryReport struct {
Available bool `json:"available"`
Path string `json:"path"`
SchemaVersion *uint64 `json:"schema_version,omitempty"`
Summary *GlossarySummary `json:"summary,omitempty"`
Reason string `json:"reason,omitempty"`
}
type GlossaryQueryReport struct {
Available bool `json:"available"`
Path string `json:"path"`
SourceText string `json:"source_text"`
Terms []GlossaryTerm `json:"terms"`
Reason string `json:"reason,omitempty"`
}
type GlossaryMutationReport struct {
Available bool `json:"available"`
Path string `json:"path"`
SchemaVersion *uint64 `json:"schema_version,omitempty"`
Deleted bool `json:"deleted,omitempty"`
Term GlossaryTerm `json:"term"`
}
type GlossaryDiagnoseReport struct {
Available bool `json:"available"`
Path string `json:"path"`
SourceText string `json:"source_text"`
Context map[string]string `json:"context,omitempty"`
Evaluation json.RawMessage `json:"evaluation"`
Reason string `json:"reason,omitempty"`
}
// LocalizedPublishParams selects the source of one localized release
// publication. TranslationFile and FromWorker are mutually exclusive.
type LocalizedPublishParams struct {
@@ -751,6 +910,54 @@ func (c *Client) TranslationMemoryConfirm(ctx context.Context, params Translatio
return &out, err
}
func (c *Client) GlossarySummary(ctx context.Context, params GlossarySummaryParams) (*GlossarySummaryReport, error) {
var out GlossarySummaryReport
_, err := c.Call(ctx, "translation.glossary.summary", params, &out)
return &out, err
}
func (c *Client) GlossaryQuery(ctx context.Context, params GlossaryQueryParams) (*GlossaryQueryReport, error) {
var out GlossaryQueryReport
_, err := c.Call(ctx, "translation.glossary.query", params, &out)
return &out, err
}
func (c *Client) GlossaryDiagnose(ctx context.Context, params GlossaryDiagnoseParams) (*GlossaryDiagnoseReport, error) {
var out GlossaryDiagnoseReport
_, err := c.Call(ctx, "translation.glossary.diagnose", params, &out)
return &out, err
}
func (c *Client) GlossaryAdd(ctx context.Context, params GlossaryTermMutationParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.add", params, &out)
return &out, err
}
func (c *Client) GlossaryUpdate(ctx context.Context, params GlossaryTermMutationParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.update", params, &out)
return &out, err
}
func (c *Client) GlossaryApprove(ctx context.Context, params GlossaryReviewParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.approve", params, &out)
return &out, err
}
func (c *Client) GlossaryDeprecate(ctx context.Context, params GlossaryReviewParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.deprecate", params, &out)
return &out, err
}
func (c *Client) GlossaryDelete(ctx context.Context, params GlossaryDeleteParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.delete", params, &out)
return &out, err
}
func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) {
return c.rawData(ctx, "localized.publish", params)
}
+270
View File
@@ -463,6 +463,12 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
UnitID: "direct:a#unit:0",
SourceText: "source",
TranslatedText: "译文",
GlossaryOverride: &GlossaryOverride{
Reviewer: "reviewer",
Reason: "approved deviation",
Provenance: "manual-review",
ConfirmedUnixSeconds: 100,
},
}},
})
if err != nil {
@@ -823,6 +829,270 @@ func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
}
}
func TestGlossaryTypedContract(t *testing.T) {
limit := uint64(20)
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
switch req.Method {
case "translation.glossary.summary":
var params GlossarySummaryParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode summary params: %v", err)
}
if params.GlossaryPath != "/var/lib/bat/glossary.sqlite" {
t.Fatalf("summary params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-summary",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"schema_version": 1,
"summary": map[string]any{
"schema_version": 1,
"term_count": 2,
"approved_count": 1,
"draft_count": 1,
},
},
}}
case "translation.glossary.query":
var params GlossaryQueryParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode query params: %v", err)
}
if params.SourceText != "Sensei" ||
params.GlossaryPath != "/var/lib/bat/glossary.sqlite" ||
params.Limit == nil || *params.Limit != limit {
t.Fatalf("query params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-query",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"terms": []any{map[string]any{
"term_id": "term-sensei",
"source_term": "Sensei",
"recommended_translation": "老师",
"priority": 10,
"review_status": "approved",
"source": map[string]any{
"source_kind": "manual",
"observed_unix_seconds": 100,
},
}},
},
}}
case "translation.glossary.diagnose":
var params GlossaryDiagnoseParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode diagnose params: %v", err)
}
if params.SourceText != "Sensei" || params.Context["destination"] != "Bundle/dialogue.bundle" {
t.Fatalf("diagnose params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-diagnose",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"source_text": "Sensei",
"evaluation": map[string]any{
"constraints": []any{map[string]any{
"term_id": "term-sensei",
"matched_source": "Sensei",
"recommended_translation": "老师",
}},
"diagnostics": []any{},
"blocked": false,
},
},
}}
case "translation.glossary.add":
var params GlossaryTermMutationParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode add params: %v", err)
}
if params.TermID != "term-sensei" || params.Source.SourceKind != "manual" ||
params.Source.ObservedUnixSeconds != 100 {
t.Fatalf("add params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-add",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{
"term_id": "term-sensei",
"source_term": "Sensei",
"recommended_translation": "老师",
"review_status": "draft",
"source": map[string]any{
"source_kind": "manual",
"observed_unix_seconds": 100,
},
},
},
}}
case "translation.glossary.update":
var params GlossaryTermMutationParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode update params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.ReviewStatus != "draft" {
t.Fatalf("update params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-update",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{
"term_id": "term-sensei",
"source_term": "Sensei",
"recommended_translation": "老师",
"review_status": "draft",
"source": map[string]any{
"source_kind": "manual",
"observed_unix_seconds": 101,
},
},
},
}}
case "translation.glossary.approve":
var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode approve params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" {
t.Fatalf("approve params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-approve",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{"term_id": "term-sensei", "review_status": "approved"},
},
}}
case "translation.glossary.deprecate":
var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode deprecate params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "retired" {
t.Fatalf("deprecate params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-deprecate",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{"term_id": "term-sensei", "review_status": "deprecated"},
},
}}
case "translation.glossary.delete":
var params GlossaryDeleteParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode delete params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "duplicate" {
t.Fatalf("delete params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-delete",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"deleted": true,
"term": map[string]any{"term_id": "term-sensei"},
},
}}
default:
t.Fatalf("unexpected method %q", req.Method)
return testResponse{}
}
})
summary, err := client.GlossarySummary(context.Background(), GlossarySummaryParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
})
if err != nil || summary.Summary == nil || summary.Summary.ApprovedCount != 1 {
t.Fatalf("summary=%#v err=%v", summary, err)
}
query, err := client.GlossaryQuery(context.Background(), GlossaryQueryParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
SourceText: "Sensei",
Limit: &limit,
})
if err != nil || len(query.Terms) != 1 || query.Terms[0].ReviewStatus != GlossaryStatusApproved {
t.Fatalf("query=%#v err=%v", query, err)
}
diagnose, err := client.GlossaryDiagnose(context.Background(), GlossaryDiagnoseParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
SourceText: "Sensei",
Context: TranslationMemoryContext{"destination": "Bundle/dialogue.bundle"},
})
if err != nil || !json.Valid(diagnose.Evaluation) {
t.Fatalf("diagnose=%#v err=%v", diagnose, err)
}
add, err := client.GlossaryAdd(context.Background(), GlossaryTermMutationParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
SourceTerm: "Sensei",
RecommendedTranslation: "老师",
ReviewStatus: "draft",
Source: GlossarySourceRecord{
SourceKind: "manual",
ObservedUnixSeconds: 100,
},
})
if err != nil || add.Term.TermID != "term-sensei" {
t.Fatalf("add=%#v err=%v", add, err)
}
updated, err := client.GlossaryUpdate(context.Background(), GlossaryTermMutationParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
SourceTerm: "Sensei",
RecommendedTranslation: "老师",
ReviewStatus: "draft",
Reviewer: "reviewer",
Source: GlossarySourceRecord{
SourceKind: "manual",
ObservedUnixSeconds: 101,
},
})
if err != nil || updated.Term.ReviewStatus != GlossaryStatusDraft {
t.Fatalf("update=%#v err=%v", updated, err)
}
approved, err := client.GlossaryApprove(context.Background(), GlossaryReviewParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
})
if err != nil || approved.Term.ReviewStatus != GlossaryStatusApproved {
t.Fatalf("approve=%#v err=%v", approved, err)
}
deprecated, err := client.GlossaryDeprecate(context.Background(), GlossaryReviewParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
Reason: "retired",
})
if err != nil || deprecated.Term.ReviewStatus != GlossaryStatusDeprecated {
t.Fatalf("deprecate=%#v err=%v", deprecated, err)
}
deleted, err := client.GlossaryDelete(context.Background(), GlossaryDeleteParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
Reason: "duplicate",
})
if err != nil || !deleted.Available || !deleted.Deleted {
t.Fatalf("delete=%#v err=%v", deleted, err)
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "task.status" {