mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0275a890bc
|
||
|
|
94483ff14d
|
+13
-7
@@ -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 都执行确定性 QA;blocking 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 fixture,Go 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 fixture,Go 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. 继续通用 Patch:manifest 驱动、双 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
@@ -261,8 +261,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
交付物:
|
||||
|
||||
1. Translation Memory V1 已使用项目级 SQLite schema:source 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 confirm;Glossary 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。
|
||||
|
||||
@@ -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 MVP(G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN 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 V1;TM 独立于 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 V1;Glossary 独立于 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
@@ -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 内冲突会 blocked,priority、scope specificity、
|
||||
匹配长度和 term ID 使用确定性排序。允许但非推荐译法只产生 warning,系统不会在译文
|
||||
生成后自动替换文本。provider、TM、人工 task update、workbench 和 localized publish
|
||||
均执行相同 QA;blocking deviation 需要 `reviewer`、`reason`、`provenance` 和确认时间
|
||||
组成显式 override。
|
||||
|
||||
---
|
||||
|
||||
## 4. 退出码
|
||||
|
||||
+145
-1
@@ -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:
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 reuse,candidate 必须显式 confirm;Glossary、
|
||||
模糊匹配和完整 Provider 体系仍属后续缺口。
|
||||
lease/retry、结果落库、项目级 Translation Memory V1 和独立 Glossary V1。TM 位于独立
|
||||
SQLite,按 raw source + 完整 context 做 trusted exact reuse,candidate 必须显式 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 MVP;Rust `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 V1,Web 术语协作视图仍待建设**;
|
||||
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
|
||||
|
||||
---
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 扩展的开发验证,不能作为当前
|
||||
资源同步或资源分发的部署前置条件。
|
||||
|
||||
### 远程开发连接
|
||||
|
||||
@@ -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 为 trusted;worker 之后才可自动复用。 |
|
||||
| `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/reason,Rust 另保留删除审计快照,返回删除前快照。 |
|
||||
|
||||
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;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法
|
||||
产生 warning;blocking 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 查询、确定性诊断和审核/删除 mutation;Go 只转发,不持有 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 或临时目录,但不能依赖开发机真实资源目录。
|
||||
|
||||
|
||||
@@ -113,11 +113,11 @@ rollback,`localized.status` 能校验当前官方 release 与 patch manifest
|
||||
|
||||
### G-012:Translation 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-013:Glossary 未实现
|
||||
### G-013:Glossary 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 QA;provider、TM、人工 task/workbench 结果都记录 QA,blocking 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。
|
||||
|
||||
@@ -95,8 +95,8 @@
|
||||
| 组件 | 路径 | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed 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` 走同一 contract;fake 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 helper;typed 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` 走同一 contract;fake 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 固定 ok;manifest/sync 走 FFI |
|
||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(¶ms, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let category = glossary_string(¶ms, "category", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let review_status = glossary_string(¶ms, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let limit = glossary_limit(¶ms, 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(¶ms, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
|
||||
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
|
||||
let context = glossary_context(¶ms, 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(¶ms, "reviewer", method)?;
|
||||
if update && reviewer.is_none() {
|
||||
return Err(glossary_invalid(method, "update 缺少 reviewer"));
|
||||
}
|
||||
let reason = glossary_string(¶ms, "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(¶ms, "term_id", method)?
|
||||
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
|
||||
let reviewer = glossary_string(¶ms, "reviewer", method)?
|
||||
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
|
||||
let reason = glossary_string(¶ms, "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(¶ms, "term_id", RPC_METHOD_GLOSSARY_DELETE)?
|
||||
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 term_id"))?;
|
||||
let reviewer = glossary_string(¶ms, "reviewer", RPC_METHOD_GLOSSARY_DELETE)?
|
||||
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reviewer"))?;
|
||||
let reason = glossary_string(¶ms, "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(
|
||||
¤t.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(
|
||||
¤t_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(¶m.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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 版本号
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¤t, &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,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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), ¶ms.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, ¶ms) {
|
||||
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, ¶ms) {
|
||||
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, ¶ms) {
|
||||
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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 或完整发布切换验证。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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" {
|
||||
|
||||
Reference in New Issue
Block a user