feat(glossary): 实现 Rust Glossary V1

This commit is contained in:
2026-09-07 22:38:53 +08:00
parent 8fc93b8f39
commit 94483ff14d
42 changed files with 4543 additions and 92 deletions
+12 -6
View File
@@ -38,6 +38,12 @@ job/unit/provider run 状态;`translation.memory.summary/query/confirm` 提供
Rust-owned TM 的摘要、source/context 查询和显式 trusted 确认,`bat-api` 仅作 Rust-owned TM 的摘要、source/context 查询和显式 trusted 确认,`bat-api` 仅作
typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取 typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取
任务,最终资源报告按 pull plan 顺序输出。 任务,最终资源报告按 pull plan 顺序输出。
项目级 Glossary V1 位于 `<output>/glossary.sqlite`,独立于 release task 和 TM
Rust `bat` 持有 term/alias/recommended/allowed/category/priority、全局或
TextUnit scope、source history 和 approved review。worker、TM 复用、人工 task
结果和 workbench publish 都执行确定性 QAblocking deviation 必须携带
reviewer/reason/provenance 的显式 override。`translation.glossary.*` 已通过
`bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。
--- ---
@@ -88,7 +94,7 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成
待完成: 待完成:
- 领域服务模块仍为空。 - 领域服务模块仍为空。
- Glossary、Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。 - Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
- 公共错误模型需要与 CLI/API 错误码统一。 - 公共错误模型需要与 CLI/API 错误码统一。
### `bat-adapters` ### `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`;实际运行报告由脚本写入隔离输出目录。 - 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`;实际运行报告由脚本写入隔离输出目录。
- 增加更多权限和极端文件系统场景测试。 - 增加更多权限和极端文件系统场景测试。
@@ -272,7 +278,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--watch --watch
``` ```
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` contract fixtureGo mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移;TM 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``localized.publish``localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。 资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` 和 Glossary query contract fixtureGo mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.*``localized.publish``localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
生产要求: 生产要求:
@@ -299,7 +305,7 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。 1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
2. 继续通用 Patchmanifest 驱动、双 release 查询和清理策略。 2. 继续通用 Patchmanifest 驱动、双 release 查询和清理策略。
3. 继续资源查询和翻译基础设施:更丰富的查询、Glossary 和 Provider 3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider
扩展体系。 扩展体系。
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。 4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
@@ -310,6 +316,6 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
`backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用; 通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;
完整 AssetBundle 重打包、完整 Web 协作后台、Glossary、模糊 TM 匹配和通用 manifest 发布未完成。 完整 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和通用 manifest 发布未完成。
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、Glossary、通用 manifest Patch - **下一工程里程碑**:复杂 AssetBundle 解析和重打包、通用 manifest Patch
构建,以及真实官方资源长期运行验证。 构建,以及真实官方资源长期运行验证。
+6 -6
View File
@@ -261,8 +261,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
交付物: 交付物:
1. Translation Memory V1 已使用项目级 SQLite schemasource raw/hash、translation、完整 context、candidate/trusted 和 provenance。 1. Translation Memory V1 已使用项目级 SQLite schemasource raw/hash、translation、完整 context、candidate/trusted 和 provenance。
2. 已实现 raw source + 完整 context exact match;模糊匹配、Glossary 联动和完整导入导出仍待实现。 2. 已实现 raw source + 完整 context exact match;模糊匹配和完整导入导出仍待实现。Glossary V1 已作为独立项目级 SQLite 资产接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override
3. 已实现显式 per-record confirm;术语优先级、别名、分类、冲突检测和审核队列仍待实现。 3. 已实现显式 per-record confirmGlossary V1 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
4. 已实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC。 4. 已实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC。
验收标准: 验收标准:
@@ -387,7 +387,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序 ## 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` `docs/reports/GO_STATUS.md`
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。 1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。
2. 基于 `translation.worker.run` 继续推进 Glossary 和 Patch 构建。 2. 基于 `translation.worker.run` 继续推进 Patch 构建。
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。 3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。 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 运行记录 - **下一份应补充的验证材料**:真实官方网络 smoke 运行记录
- **下一项工程任务**:推进 TM 扩展、Glossary、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。 - **下一项工程任务**:推进 TM/Glossary 扩展、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
+4 -4
View File
@@ -13,14 +13,14 @@
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。 - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 - `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 的默认路径。 - `internal/backendrpc`Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVPG-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation/TM admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。 - `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVPG-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation/TM admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。 - Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 官方同步会维护 `<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 发布后写入 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 交接。 - 新 release 发布后会生成 `official-resource-changes.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-translation-handoff.json``crowdin-textunit-queue.json``translation-tasks.sqlite``translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库Translation Memory V1TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。Glossary、模糊匹配和完整 Provider 扩展体系仍待实现。 - `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库Translation Memory V1 和 Glossary V1Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。 - `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-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 或生产同步的主集成边界。 - `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 已可用。 - `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。 - 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。 - 复杂 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 协作后台。 - SDK、完整 Web 协作后台。
详细状态见: 详细状态见:
@@ -191,7 +191,7 @@ BlueArchiveToolkit/
1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard`cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,不应误写成完整产品 CLI。 1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard`cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,不应误写成完整产品 CLI。
2. 补齐 AssetBundle UnityFS 引擎级解析。 2. 补齐 AssetBundle UnityFS 引擎级解析。
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。 3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
4. 基于 `translation.worker.run` provider worker 扩展 Glossary、完整 Patch 构建和发布/回滚闭环。 4. 基于 `translation.worker.run` provider worker 继续推进完整 Patch 构建和发布/回滚闭环。
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。 5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在 TM 扩展、权限模型和持久化 API 明确后推进。 当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在 TM 扩展、权限模型和持久化 API 明确后推进。
+36 -1
View File
@@ -152,6 +152,9 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
| `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token | | `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token |
| `GET /admin/translation/memory/summary` | 读取 Rust TM schema、记录总数及 candidate/trusted 等状态计数;需要管理 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/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 | | `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token |
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 | | `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
@@ -187,6 +190,10 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task | | `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 | | `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
| `translation-memory-confirm` | `translation.memory.confirm` | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | `202` + 已确认的 TM 记录 | | `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 |
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest | | `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 | | `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
@@ -286,7 +293,7 @@ curl -i -H 'Range: bytes=0-1023' \
- `config.toml` 的字段按职责分组:`[runtime]``[resource]``[localized]``[repository]``[network]``[translation.worker]` - `config.toml` 的字段按职责分组:`[runtime]``[resource]``[localized]``[repository]``[network]``[translation.worker]`
- 现有 `BAT_*` 环境变量仍然有效,可继续覆盖 `config.toml` 中的同名配置。 - 现有 `BAT_*` 环境变量仍然有效,可继续覆盖 `config.toml` 中的同名配置。
- `BAT_SKIP_ENV_FILE` 已废弃且不再影响启动。 - `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` 等子命令不受它们影响。 - `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时运行模式设置让位。`status` / `verify` 等子命令不受它们影响。
- 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。 - 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json` - `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`
@@ -311,6 +318,34 @@ RPC `translation.memory.summary`、`translation.memory.query`、`translation.mem
context 不完整或不一致、normalized source 辅助命中和 workflow `proofread` 都不会自动 context 不完整或不一致、normalized source 辅助命中和 workflow `proofread` 都不会自动
建立 trusted 记录。 建立 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"}'
```
只有 `approved` term 会进入 provider constraints 和 TM 自动复用前的 QA。
scope 为空表示全局;同一 TextUnit 内冲突会 blockedpriority、scope specificity、
匹配长度和 term ID 使用确定性排序。允许但非推荐译法只产生 warning,系统不会在译文
生成后自动替换文本。provider、TM、人工 task update、workbench 和 localized publish
均执行相同 QAblocking deviation 需要 `reviewer``reason``provenance` 和确认时间
组成显式 override。
--- ---
## 4. 退出码 ## 4. 退出码
+130 -1
View File
@@ -454,6 +454,86 @@ paths:
description: Missing or invalid admin token. description: Missing or invalid admin token.
"503": "503":
description: Rust bat Translation Memory backend is unavailable. 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: /admin/translation/status:
get: get:
summary: Read Rust-owned localized release status summary: Read Rust-owned localized release status
@@ -473,7 +553,7 @@ paths:
required: true required: true
schema: schema:
type: string 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, localized-publish, localized-rollback]
requestBody: requestBody:
required: false required: false
content: content:
@@ -566,8 +646,57 @@ paths:
type: string type: string
translation_memory_path: translation_memory_path:
type: string type: string
glossary_path:
type: string
record_id: record_id:
type: string 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: reviewer:
type: string type: string
reason: reason:
+690
View File
@@ -0,0 +1,690 @@
//! 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(),
));
}
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));
}
}
+7
View File
@@ -2,12 +2,19 @@
pub mod game_client; pub mod game_client;
pub mod game_version; pub mod game_version;
pub mod glossary;
pub mod resource; pub mod resource;
pub mod translation; pub mod translation;
pub mod translation_memory; pub mod translation_memory;
pub use game_client::{ClientStatus, GameClient, GameRegion}; pub use game_client::{ClientStatus, GameClient, GameRegion};
pub use game_version::{GameVersion, UnityVersion}; 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::{ pub use resource::{
crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType, crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType,
}; };
@@ -0,0 +1,15 @@
//! Glossary repository boundary.
use crate::domain::{GlossaryEvaluation, TranslationMemoryContext};
use async_trait::async_trait;
/// Read-only matching boundary consumed by translation workers.
#[async_trait]
pub trait GlossaryRepository: Send + Sync {
/// Evaluates approved terms against one source TextUnit.
async fn evaluate(
&self,
source_text: &str,
context: &TranslationMemoryContext,
) -> crate::Result<GlossaryEvaluation>;
}
+2
View File
@@ -3,11 +3,13 @@
//! 定义所有数据访问接口 //! 定义所有数据访问接口
pub mod cas_repository; pub mod cas_repository;
pub mod glossary_repository;
pub mod resource_repository; pub mod resource_repository;
pub mod translation_memory_repository; pub mod translation_memory_repository;
pub mod translation_repository; pub mod translation_repository;
pub use cas_repository::CasRepository; pub use cas_repository::CasRepository;
pub use glossary_repository::GlossaryRepository;
pub use resource_repository::ResourceRepository; pub use resource_repository::ResourceRepository;
pub use translation_memory_repository::TranslationMemoryRepository; pub use translation_memory_repository::TranslationMemoryRepository;
pub use translation_repository::TranslationRepository; pub use translation_repository::TranslationRepository;
+7 -6
View File
@@ -196,13 +196,14 @@ pub struct ParserRegistry {
### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担) ### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担)
当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、 当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、
lease/retry、结果落库项目级 Translation Memory V1。TM 位于独立 SQLite,按 raw lease/retry、结果落库项目级 Translation Memory V1 和独立 Glossary V1。TM 位于独立
source + 完整 context 做 trusted exact reusecandidate 必须显式 confirmGlossary、 SQLite,按 raw source + 完整 context 做 trusted exact reusecandidate 必须显式 confirm
模糊匹配和完整 Provider 体系仍属后续缺口。 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 审核队列 PostgreSQL 审核队列
``` ```
@@ -295,7 +296,7 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
### 7. Web 后台 (Vue 3,目标设计) ### 7. Web 后台 (Vue 3,目标设计)
当前只有 `bat-api` 内嵌 dashboard MVP;登录、角色、术语管理和完整协作审核仍未实现。 当前只有 `bat-api` 内嵌 dashboard MVPRust `bat` 的 Glossary V1 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。
**技术栈** **技术栈**
- Vue 3 + Composition API - Vue 3 + Composition API
@@ -308,7 +309,7 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
**模块** **模块**
- Dashboard(统计概览) - Dashboard(统计概览)
- 翻译审核(Translation Review - 翻译审核(Translation Review
- 术语管理(Glossary Manager - Web 术语管理(Glossary Manager
- 资源浏览(Asset Browser - 资源浏览(Asset Browser
- 用户管理(User Management - 用户管理(User Management
@@ -36,7 +36,7 @@
- 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由 - 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由
`internal/backendrpc` 消费。 `internal/backendrpc` 消费。
4. **完整游戏业务 API、完整 Web 协作后台、Glossary 和 Provider 扩展体系仍是后续目标** 4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标Rust `bat` 已持有 Glossary V1Web 术语协作视图仍待建设**
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。 Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
--- ---
+1 -1
View File
@@ -89,7 +89,7 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
当前开发优先推进: 当前开发优先推进:
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。 1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
2. 基于 `translation.worker.run` 扩展 TM/Glossary通用 manifest Patch 构建。 2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并推进通用 manifest Patch 构建。
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。 3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
4.`docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。 4.`docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。 5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
+16
View File
@@ -161,6 +161,22 @@ bat i18n worker run \
`--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须 `--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须
显式指定 `--interval` 显式指定 `--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 的任务状态: 外部 provider 或人工流程也可以用 `i18n task update` 回写当前 release 的任务状态:
```bash ```bash
+2 -2
View File
@@ -8,7 +8,7 @@ BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式:
本地资源状态使用文件和 SQLite。 本地资源状态使用文件和 SQLite。
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。 2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。
3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root` 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 Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory V1
使用 `<output>/translation-memory.sqlite` 使用 `<output>/translation-memory.sqlite`
5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。 5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。
@@ -39,7 +39,7 @@ docker compose -f deployments/docker-compose.dev.yml --profile local-db up -d
## 模式 2:可选数据库开发环境(目标能力) ## 模式 2:可选数据库开发环境(目标能力)
PostgreSQL 和 Redis 不是当前 `bat` / `bat-api` 的生产运行依赖。本模式只用于未来 PostgreSQL 和 Redis 不是当前 `bat` / `bat-api` 的生产运行依赖。本模式只用于未来
服务层、Glossary 或 Provider 扩展的开发验证,不能作为当前 服务层、Web 协作视图或 Provider 扩展的开发验证,不能作为当前
资源同步或资源分发的部署前置条件。 资源同步或资源分发的部署前置条件。
### 远程开发连接 ### 远程开发连接
+29 -2
View File
@@ -164,6 +164,11 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
provenance。默认路径为 `<output>/translation-memory.sqlite`,可由 provenance。默认路径为 `<output>/translation-memory.sqlite`,可由
`BAT_TRANSLATION_MEMORY_PATH``[translation.worker].translation_memory_path` 或 CLI `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。 删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
@@ -218,6 +223,13 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
| `translation.memory.summary` | 已实现 | 可选 `{ "translation_memory_path": "..." }` | 返回 TM schema 版本、总记录数及 candidate/trusted/rejected/superseded 状态计数。 | | `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.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenance。 |
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | 显式确认一条 candidate 为 trustedworker 之后才可自动复用。 | | `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | 显式确认一条 candidate 为 trustedworker 之后才可自动复用。 |
| `translation.glossary.summary` | 已实现 | 可选 `{ "glossary_path": "..." }` | 返回 Glossary schema 版本和 draft/approved/deprecated/rejected 计数;缺库只返回 `available=false`,不会创建空库。 |
| `translation.glossary.query` | 已实现 | `{ "source_text": "...", "category": "...", "review_status": "approved", "limit": 100 }` | 查询 term、alias、scope、source provenance 和完整 source/review history。 |
| `translation.glossary.diagnose` | 已实现 | `{ "source_text": "...", "context": {...} }` | 只对 approved term 生成 provider-neutral constraints,并返回冲突/覆盖诊断和 blocked 决策。 |
| `translation.glossary.add` | 已实现 | Glossary term draft,包含 `term_id``source_term``recommended_translation``source` 等 | Rust 创建 draft/import term 并记录 source history。 |
| `translation.glossary.update` | 已实现 | term draft + `reviewer`,可选 `reason` | Rust 替换 term definition,并记录 source/review history。 |
| `translation.glossary.approve` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 将 term 明确置为 approved;只有 approved term 进入 worker/TM 自动流程。 |
| `translation.glossary.deprecate` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 保留历史但停止自动应用。 |
TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted` TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted`
context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会 context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会
@@ -299,6 +311,7 @@ provider worker 参数:
| `max_tasks` | uint/null | `null` | 本轮最多 claim 的任务数,设置时必须大于 0。 | | `max_tasks` | uint/null | `null` | 本轮最多 claim 的任务数,设置时必须大于 0。 |
| `worker_id` | string | `bat-rpc-worker` | lease 诊断用 worker ID 前缀。 | | `worker_id` | string | `bat-rpc-worker` | lease 诊断用 worker ID 前缀。 |
| `translation_memory_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 TM 数据库路径;未指定时使用 worker 配置或 `<output>/translation-memory.sqlite`。 | | `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;字符串数字、负数和越界值会返回 数字字段必须是 JSON number;字符串数字、负数和越界值会返回
`BAT-ERR-700002``mock` provider 在没有 fixture 时把 source text 写成可诊断的 `BAT-ERR-700002``mock` provider 在没有 fixture 时把 source text 写成可诊断的
@@ -306,6 +319,12 @@ mock 译文;`crowdin` provider 从 `CROWDIN_PROJECT_ID`、`CROWDIN_LANGUAGE_ID
`CROWDIN_API_TOKEN` 读取配置,可选 `CROWDIN_API_BASE_URL``BAT_CURL` `CROWDIN_API_TOKEN` 读取配置,可选 `CROWDIN_API_BASE_URL``BAT_CURL`
token 不会进入报告、任务记录或调试输出。 token 不会进入报告、任务记录或调试输出。
Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 trusted TM
复用前、provider 返回后、人工 `translation.task.update` 和 workbench publish 前执行
同一套确定性 QA;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法
产生 warningblocking deviation 必须在对应结果中提交 `glossary_override`,并包含
`reviewer``reason``provenance` 和确认时间。系统不会在译文生成后做静默字符串替换。
### localized ### localized
| 方法 | 状态 | params | data | | 方法 | 状态 | params | data |
@@ -449,6 +468,10 @@ CLI 对应关系:
| `bat i18n proofread` | `translation.proofread` | | `bat i18n proofread` | `translation.proofread` |
| `bat i18n memory summary` / `bat i18n memory query` | `translation.memory.summary` / `translation.memory.query` | | `bat i18n memory summary` / `bat i18n memory query` | `translation.memory.summary` / `translation.memory.query` |
| `bat i18n memory confirm` | `translation.memory.confirm` | | `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 localized-status` | `localized.status` | | `bat localized-status` | `localized.status` |
| `bat resource-index` | `resource.index` | | `bat resource-index` | `resource.index` |
@@ -481,7 +504,9 @@ CLI 对应关系:
`localized.status``localized.publish``localized.rollback` `localized.status``localized.publish``localized.rollback`
`translation.tasks``translation.handoff``translation.task.update` `translation.tasks``translation.handoff``translation.task.update`
`translation.worker.run``translation.proofread``translation.memory.summary` `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`
`task.*` 和三个 `unityfs.patch_*` 方法。 `task.*` 和三个 `unityfs.patch_*` 方法。
- `resource.index``patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守 - `resource.index``patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
本契约的参数和响应定义。 本契约的参数和响应定义。
@@ -498,6 +523,7 @@ CLI 对应关系:
| `ParseBackend` | `parse.status``parse.text_units``parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 | | `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 触发与人工校对标记 | | `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 状态 | | `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` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核 mutation;Go 只转发,不持有 Glossary 状态 |
| `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 | | `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
`daemon.stop``daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。 `daemon.stop``daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
@@ -507,7 +533,8 @@ Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖 Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
`catalog.status` available/unavailable、`resource.manifest` page0、对应 `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 输出形状归一化而来,只用于 这些 fixture/mirror 由 Rust 输出形状归一化而来,只用于
schema / mirror 回归;live daemon socket 和完整 fixture release 切换由 schema / mirror 回归;live daemon socket 和完整 fixture release 切换由
`make bat-api-local-live-smoke` 在同机 `/tmp` 隔离环境中验证。该 smoke 不替代 `make bat-api-local-live-smoke` 在同机 `/tmp` 隔离环境中验证。该 smoke 不替代
@@ -50,11 +50,13 @@
catalog-status.unavailable.raw.json catalog-status.unavailable.raw.json
resource-manifest.page0.raw.json resource-manifest.page0.raw.json
official-sync-snapshot.raw.json official-sync-snapshot.raw.json
glossary-query.raw.json
normalized/ normalized/
catalog-status.available.json catalog-status.available.json
catalog-status.unavailable.json catalog-status.unavailable.json
resource-manifest.page0.json resource-manifest.page0.json
official-sync-snapshot.json official-sync-snapshot.json
glossary-query.json
notes.md notes.md
``` ```
@@ -72,6 +74,8 @@ Rust 窗口请基于当前真实代码生成或导出以下 JSON:
2. `catalog.status` available=false 响应。 2. `catalog.status` available=false 响应。
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。 3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
4. 对应 release 的 `official-sync-snapshot.json` 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 或临时目录,但不能依赖开发机真实资源目录。 输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
+3 -3
View File
@@ -115,9 +115,9 @@ rollback`localized.status` 能校验当前官方 release 与 patch manifest
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。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go 不持有 TM 状态。仍缺少模糊匹配、Glossary 联动和更丰富的导入导出历史能力。
### G-013Glossary 未实现 ### G-013Glossary V1 已实现,协作视图仍缺失
需要支持术语优先级、别名、分类、冲突检测和审核 Rust `bat` 已提供独立项目级 `glossary.sqlite`term/alias/recommended/allowed/category/priority、全局与 TextUnit scope、source history、approved review、冲突诊断、provider-neutral constraints 和确定性 QA 均由 Rust 持有。trusted TM 复用会先经过 Glossary QAprovider、TM、人工 task/workbench 结果都记录 QAblocking deviation 必须显式提交 reviewer/reason/provenance。`translation.glossary.*` 已通过 `bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图和更丰富的导入/搜索能力
### G-014:完整 Provider 扩展体系未实现 ### G-014:完整 Provider 扩展体系未实现
@@ -145,7 +145,7 @@ Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整
1. 继续 G-005:真实 AssetBundle 样本、复杂字段解析和发布级重打包。 1. 继续 G-005:真实 AssetBundle 样本、复杂字段解析和发布级重打包。
2. 继续 G-006/G-011D:通用 manifest Patch 和双 release 查询/清理策略。 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`,补充真实网络长期运行报告。 4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
5. 最后推进完整 Web 协作后台和完整游戏业务 API。 5. 最后推进完整 Web 协作后台和完整游戏业务 API。
+2 -2
View File
@@ -95,8 +95,8 @@
| 组件 | 路径 | 状态 | 说明 | | 组件 | 路径 | 状态 | 说明 |
|---|---|---|---| |---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 | | Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | | RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.summary/query/diagnose/add/update/approve/deprecate` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM admin forwarding + 内嵌 dashboard + `.env` | | 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
| 空骨架 | `api/``pkg/*`、部分 `internal/*` | **空** | 见各目录 README | | 空骨架 | `api/``pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
+353 -3
View File
@@ -13,8 +13,9 @@ use bat_infrastructure::{
localized_patch_operations, open_append_file, read_download_manifest_at, read_file_no_symlink, 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_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at,
read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state, read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state,
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
validate_output_root, validate_runtime_state_dir, validate_translation_workbench, 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, write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport, LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService, OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
@@ -54,6 +55,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[path = "config_file.rs"] #[path = "config_file.rs"]
mod config_file; mod config_file;
#[path = "glossary_query.rs"]
mod glossary_query;
#[path = "patch_commands.rs"] #[path = "patch_commands.rs"]
mod patch_commands; mod patch_commands;
#[path = "readonly_query.rs"] #[path = "readonly_query.rs"]
@@ -70,6 +73,11 @@ mod terminal_output;
mod translation_query; mod translation_query;
#[path = "workflow_commands.rs"] #[path = "workflow_commands.rs"]
mod workflow_commands; mod workflow_commands;
use glossary_query::{
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::{ use patch_commands::{
is_write_patch_command, run_write_patch_command, validate_write_patch_options, is_write_patch_command, run_write_patch_command, validate_write_patch_options,
}; };
@@ -229,6 +237,16 @@ fn run() -> anyhow::Result<i32> {
run_translation_memory_command(&options)?; run_translation_memory_command(&options)?;
Ok(0) Ok(0)
} }
CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose => {
run_glossary_command(&options)?;
Ok(0)
}
CliCommand::TranslationWorker => { CliCommand::TranslationWorker => {
run_repeated_workflow(&options, "translation-worker", run_translation_worker)?; run_repeated_workflow(&options, "translation-worker", run_translation_worker)?;
Ok(0) Ok(0)
@@ -385,6 +403,8 @@ struct CliOptions {
translation_provider: Option<String>, translation_provider: Option<String>,
translation_fixture: Option<PathBuf>, translation_fixture: Option<PathBuf>,
translation_memory_path: Option<PathBuf>, translation_memory_path: Option<PathBuf>,
glossary_path: Option<PathBuf>,
glossary_path_option_explicit: bool,
translation_memory_option_explicit: bool, translation_memory_option_explicit: bool,
translation_memory_command_option_explicit: bool, translation_memory_command_option_explicit: bool,
translation_memory_source_text: Option<String>, translation_memory_source_text: Option<String>,
@@ -392,6 +412,26 @@ struct CliOptions {
translation_memory_record_id: Option<String>, translation_memory_record_id: Option<String>,
translation_memory_reviewer: Option<String>, translation_memory_reviewer: Option<String>,
translation_memory_reason: 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_concurrency: usize,
worker_max_attempts: u32, worker_max_attempts: u32,
worker_lease_seconds: u64, worker_lease_seconds: u64,
@@ -488,6 +528,8 @@ impl Default for CliOptions {
translation_provider: None, translation_provider: None,
translation_fixture: None, translation_fixture: None,
translation_memory_path: None, translation_memory_path: None,
glossary_path: None,
glossary_path_option_explicit: false,
translation_memory_option_explicit: false, translation_memory_option_explicit: false,
translation_memory_command_option_explicit: false, translation_memory_command_option_explicit: false,
translation_memory_source_text: None, translation_memory_source_text: None,
@@ -495,6 +537,26 @@ impl Default for CliOptions {
translation_memory_record_id: None, translation_memory_record_id: None,
translation_memory_reviewer: None, translation_memory_reviewer: None,
translation_memory_reason: 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_concurrency: DEFAULT_TRANSLATION_CONCURRENCY,
worker_max_attempts: DEFAULT_TRANSLATION_MAX_ATTEMPTS, worker_max_attempts: DEFAULT_TRANSLATION_MAX_ATTEMPTS,
worker_lease_seconds: DEFAULT_TRANSLATION_LEASE_SECONDS, worker_lease_seconds: DEFAULT_TRANSLATION_LEASE_SECONDS,
@@ -590,6 +652,13 @@ enum CliCommand {
TranslationMemorySummary, TranslationMemorySummary,
TranslationMemoryQuery, TranslationMemoryQuery,
TranslationMemoryConfirm, TranslationMemoryConfirm,
GlossarySummary,
GlossaryQuery,
GlossaryAdd,
GlossaryUpdate,
GlossaryApprove,
GlossaryDeprecate,
GlossaryDiagnose,
Repack, Repack,
PublishLocalized, PublishLocalized,
LocalizedRollback, LocalizedRollback,
@@ -1107,6 +1176,13 @@ 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_SUMMARY: &str = "translation.memory.summary";
const RPC_METHOD_TRANSLATION_MEMORY_QUERY: &str = "translation.memory.query"; const RPC_METHOD_TRANSLATION_MEMORY_QUERY: &str = "translation.memory.query";
const RPC_METHOD_TRANSLATION_MEMORY_CONFIRM: &str = "translation.memory.confirm"; 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_DIAGNOSE: &str = "translation.glossary.diagnose";
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status"; const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish"; const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback"; const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
@@ -2105,7 +2181,11 @@ fn dispatch_rpc_method(
RPC_METHOD_TRANSLATION_TASK_UPDATE => rpc_envelope_from_result( RPC_METHOD_TRANSLATION_TASK_UPDATE => rpc_envelope_from_result(
request_id, request_id,
"translation.task.update", "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 => { RPC_METHOD_TRANSLATION_PROOFREAD => {
let _sync_guard = tasks let _sync_guard = tasks
@@ -2155,6 +2235,67 @@ fn dispatch_rpc_method(
request.params.as_ref(), 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_TRANSLATION_WORKER_RUN => { RPC_METHOD_TRANSLATION_WORKER_RUN => {
let config = match rpc_translation_worker_config_with_defaults( let config = match rpc_translation_worker_config_with_defaults(
request.params.as_ref(), request.params.as_ref(),
@@ -3504,6 +3645,13 @@ fn rpc_translation_worker_config_with_defaults(
)? )?
.map(PathBuf::from) .map(PathBuf::from)
.or_else(|| defaults.translation_memory_path.clone()), .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| { config.validate().map_err(|error| {
ApiError::new( ApiError::new(
@@ -6109,6 +6257,10 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
args.push("--translation-memory-path".to_string()); args.push("--translation-memory-path".to_string());
args.push(path.to_string_lossy().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("--worker-concurrency".to_string());
args.push(options.worker_concurrency.to_string()); args.push(options.worker_concurrency.to_string());
args.push("--worker-max-attempts".to_string()); args.push("--worker-max-attempts".to_string());
@@ -6420,6 +6572,9 @@ fn apply_bat_env_overrides(
{ {
options.translation_memory_path = Some(PathBuf::from(v)); 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") { if let Some(v) = value("BAT_TRANSLATION_CONCURRENCY") {
options.worker_concurrency = options.worker_concurrency =
parse_translation_worker_concurrency(&v, "环境变量 BAT_TRANSLATION_CONCURRENCY")?; parse_translation_worker_concurrency(&v, "环境变量 BAT_TRANSLATION_CONCURRENCY")?;
@@ -6702,6 +6857,10 @@ fn parse_args_with_env(
options.translation_memory_option_explicit = true; options.translation_memory_option_explicit = true;
options.translation_worker_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" => { "--tm-source-text" => {
options.translation_memory_source_text = Some(next_option_value(&mut args, &flag)?); options.translation_memory_source_text = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true; options.translation_memory_command_option_explicit = true;
@@ -6723,6 +6882,70 @@ fn parse_args_with_env(
options.translation_memory_reason = Some(next_option_value(&mut args, &flag)?); options.translation_memory_reason = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true; 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" => { "--worker-concurrency" | "--translation-concurrency" => {
options.worker_concurrency = parse_translation_worker_concurrency( options.worker_concurrency = parse_translation_worker_concurrency(
&next_option_value(&mut args, &flag)?, &next_option_value(&mut args, &flag)?,
@@ -7240,6 +7463,30 @@ fn parse_args_with_env(
"翻译 worker 参数只适用于 i18n worker run 或 daemon restart/reload" "翻译 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::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 if options.translation_memory_option_explicit
&& !matches!( && !matches!(
options.command, options.command,
@@ -7247,6 +7494,13 @@ fn parse_args_with_env(
| CliCommand::TranslationMemorySummary | CliCommand::TranslationMemorySummary
| CliCommand::TranslationMemoryQuery | CliCommand::TranslationMemoryQuery
| CliCommand::TranslationMemoryConfirm | CliCommand::TranslationMemoryConfirm
| CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose
| CliCommand::Restart | CliCommand::Restart
| CliCommand::Reload | CliCommand::Reload
) )
@@ -7369,6 +7623,74 @@ fn parse_args_with_env(
options.progress = false; options.progress = false;
options.banner = false; options.banner = false;
} }
CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| 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"
));
}
}
_ => unreachable!(),
}
options.progress = false;
options.banner = false;
}
CliCommand::PatchApply CliCommand::PatchApply
| CliCommand::UnityFsPatchTextAsset | CliCommand::UnityFsPatchTextAsset
| CliCommand::UnityFsPatchStringField | CliCommand::UnityFsPatchStringField
@@ -7916,6 +8238,9 @@ fn parse_translation_command(
if action == "memory" || action == "tm" { if action == "memory" || action == "tm" {
return parse_translation_memory_command(args, options); 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() { let command = match action.as_str() {
"run" => CliCommand::Translate, "run" => CliCommand::Translate,
"export" => CliCommand::Translate, "export" => CliCommand::Translate,
@@ -7956,6 +8281,30 @@ fn parse_translation_memory_command(
Ok(()) 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,
"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( fn parse_translation_worker_command(
args: &mut impl Iterator<Item = String>, args: &mut impl Iterator<Item = String>,
options: &mut CliOptions, options: &mut CliOptions,
@@ -8086,6 +8435,7 @@ fn translation_worker_config_from_options(
.clone() .clone()
.unwrap_or_else(|| default_worker_id.to_string()), .unwrap_or_else(|| default_worker_id.to_string()),
translation_memory_path: options.translation_memory_path.clone(), translation_memory_path: options.translation_memory_path.clone(),
glossary_path: options.glossary_path.clone(),
}; };
config.validate()?; config.validate()?;
Ok(config) Ok(config)
+105
View File
@@ -366,6 +366,77 @@ fn translation_memory_subcommands_reject_irrelevant_options() {
assert!(error.to_string().contains("query 参数")); 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);
assert!(parse(&["bat", "i18n", "glossary", "diagnose"]).is_err());
assert!(parse(&[
"bat",
"i18n",
"glossary",
"query",
"--glossary-term-id",
"term-sensei",
])
.is_err());
}
#[test] #[test]
fn translation_worker_env_defaults_apply() { fn translation_worker_env_defaults_apply() {
let options = parse_with_env( let options = parse_with_env(
@@ -765,6 +836,8 @@ fn translation_workbench_commands_read_update_and_clear_entries() {
review_status: None, review_status: None,
format: Some("plain".to_string()), format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()), text_source_kind: Some("text_asset".to_string()),
glossary_qa: None,
glossary_override: None,
}], }],
}; };
bat_infrastructure::write_translation_workbench(&path, &workbench).unwrap(); bat_infrastructure::write_translation_workbench(&path, &workbench).unwrap();
@@ -2679,6 +2752,38 @@ 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] #[test]
fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() { fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
let temp = tempfile::TempDir::new().unwrap(); let temp = tempfile::TempDir::new().unwrap();
@@ -67,6 +67,7 @@ download_concurrency = 8
provider = 'mock' provider = 'mock'
fixture = '' fixture = ''
translation_memory_path = '' translation_memory_path = ''
glossary_path = ''
concurrency = 8 concurrency = 8
max_attempts = 3 max_attempts = 3
lease_seconds = 300 lease_seconds = 300
@@ -150,6 +151,7 @@ struct TranslationWorkerSection {
provider: Option<String>, provider: Option<String>,
fixture: Option<PathBuf>, fixture: Option<PathBuf>,
translation_memory_path: Option<PathBuf>, translation_memory_path: Option<PathBuf>,
glossary_path: Option<PathBuf>,
concurrency: Option<usize>, concurrency: Option<usize>,
max_attempts: Option<u32>, max_attempts: Option<u32>,
lease_seconds: Option<u64>, lease_seconds: Option<u64>,
@@ -367,6 +369,9 @@ impl BatConfigFile {
if let Some(value) = self.translation.worker.translation_memory_path.as_ref() { if let Some(value) = self.translation.worker.translation_memory_path.as_ref() {
options.translation_memory_path = Some(value.clone()); 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 { if let Some(value) = self.translation.worker.concurrency {
options.worker_concurrency = value; options.worker_concurrency = value;
} }
@@ -591,6 +596,10 @@ impl BatConfigFile {
line_number, line_number,
)?; )?;
} }
(SectionPath::TranslationWorker, "glossary_path") => {
self.translation.worker.glossary_path =
parse_optional_path(value, "translation.worker.glossary_path", line_number)?;
}
(SectionPath::TranslationWorker, "concurrency") => { (SectionPath::TranslationWorker, "concurrency") => {
self.translation.worker.concurrency = Some(parse_translation_worker_concurrency( self.translation.worker.concurrency = Some(parse_translation_worker_concurrency(
&parse_scalar_text(value, "translation.worker.concurrency", line_number)?, &parse_scalar_text(value, "translation.worker.concurrency", line_number)?,
@@ -0,0 +1,649 @@
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(),
)?
}
_ => 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::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));
}
}
_ => 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 glossary_rpc_envelope(
request_id: String,
result: Result<serde_json::Value, ApiError>,
) -> RpcEnvelope {
match result {
Ok(data) => rpc_envelope_ok(request_id, "ok", data),
Err(error) => rpc_envelope_error(request_id, error),
}
}
pub(super) fn glossary_summary_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
params,
RPC_METHOD_GLOSSARY_SUMMARY,
)?;
build_glossary_summary_report(&path)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_SUMMARY, error))
}
pub(super) fn glossary_query_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_QUERY)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
let category = glossary_string(&params, "category", RPC_METHOD_GLOSSARY_QUERY)?;
let review_status = glossary_string(&params, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
let limit = glossary_limit(&params, RPC_METHOD_GLOSSARY_QUERY)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_QUERY,
)?;
build_glossary_query_report(&path, source_text, category, review_status, limit)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_QUERY, error))
}
pub(super) fn glossary_diagnose_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
let context = glossary_context(&params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_DIAGNOSE,
)?;
build_glossary_diagnose_report(&path, source_text, context)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DIAGNOSE, error))
}
pub(super) fn glossary_mutation_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
update: bool,
) -> Result<serde_json::Value, ApiError> {
let method = if update {
RPC_METHOD_GLOSSARY_UPDATE
} else {
RPC_METHOD_GLOSSARY_ADD
};
let params = glossary_params(params, method)?;
let draft: GlossaryTermDraft =
serde_json::from_value(serde_json::Value::Object(params.clone())).map_err(|error| {
glossary_invalid(method, format!("Glossary term 参数无效:{error}"))
})?;
let reviewer = glossary_string(&params, "reviewer", method)?;
if update && reviewer.is_none() {
return Err(glossary_invalid(method, "update 缺少 reviewer"));
}
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_mutation_report(&path, &draft, update, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
pub(super) fn glossary_review_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
status: GlossaryReviewStatus,
) -> Result<serde_json::Value, ApiError> {
let method = if status == GlossaryReviewStatus::Approved {
RPC_METHOD_GLOSSARY_APPROVE
} else {
RPC_METHOD_GLOSSARY_DEPRECATE
};
let params = glossary_params(params, method)?;
let term_id = glossary_string(&params, "term_id", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
let reviewer = glossary_string(&params, "reviewer", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_review_report(&path, term_id, status, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
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 可用", format_bool(self.translation_memory_available));
print_field("TM 命中 TextUnit", self.translation_memory_hit_count); print_field("TM 命中 TextUnit", self.translation_memory_hit_count);
print_field("Provider TextUnit", self.provider_unit_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 { for failure in &self.translation_memory_failures {
println!(" - TM: {failure}"); println!(" - TM: {failure}");
} }
for failure in &self.glossary_failures {
println!(" - Glossary: {failure}");
}
for failure in &self.failures { for failure in &self.failures {
println!( println!(
" - {} [{}] retryable={} {}", " - {} [{}] retryable={} {}",
@@ -345,6 +345,10 @@ Commands:
i18n handoff Query current translation handoff i18n handoff Query current translation handoff
i18n status Show localized release status for current official release i18n status Show localized release status for current official release
i18n task update Update one provider worker task status 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 diagnose Run deterministic Glossary QA for one TextUnit source
i18n publish Publish a localized release from a workbench or worker results i18n publish Publish a localized release from a workbench or worker results
i18n rollback Roll back the current localized release i18n rollback Roll back the current localized release
i18n schedule Manage translation schedules i18n schedule Manage translation schedules
@@ -384,6 +388,7 @@ Examples:
{binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1 {binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1
{binary} i18n proofread --json {binary} i18n proofread --json
{binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s {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 tasks --json
{binary} i18n handoff --json {binary} i18n handoff --json
{binary} i18n status --json {binary} i18n status --json
@@ -433,6 +438,15 @@ Sync:
--provider-run-id <ID> Provider run ID for i18n task update --provider-run-id <ID> Provider run ID for i18n task update
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin) --translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
--translation-fixture <PATH> Mock/provider fixture for i18n worker run --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
--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
--glossary-reason <TEXT> Reason for Glossary review or override
--glossary-provenance <TEXT> Provenance for an explicit Glossary override
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256) --worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
--worker-max-attempts <N> Maximum claims per translation task --worker-max-attempts <N> Maximum claims per translation task
--worker-lease-seconds <N> Lease seconds for one claimed task --worker-lease-seconds <N> Lease seconds for one claimed task
@@ -1,6 +1,6 @@
use super::report_output::print_json_value; use super::report_output::print_json_value;
use super::*; use super::*;
use bat_core::domain::TranslationMemoryContext; use bat_core::domain::{GlossaryOverride, TranslationMemoryContext};
use bat_core::repositories::TranslationMemoryRepository; use bat_core::repositories::TranslationMemoryRepository;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -9,6 +9,8 @@ struct TranslationTaskResultUpdateParam {
unit_id: String, unit_id: String,
source_text: String, source_text: String,
translated_text: String, translated_text: String,
#[serde(default)]
glossary_override: Option<GlossaryOverride>,
} }
pub(super) fn build_translation_tasks_report( 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( pub(super) fn update_translation_task_status_report(
state_dir: &Path, state_dir: &Path,
params: Option<&serde_json::Value>, params: Option<&serde_json::Value>,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<serde_json::Value> { ) -> anyhow::Result<serde_json::Value> {
let task_id = rpc_string_param(params, "task_id") let task_id = rpc_string_param(params, "task_id")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 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) .find(task_id)
.await .await
.map_err(|error| anyhow::anyhow!("{error}"))?; .map_err(|error| anyhow::anyhow!("{error}"))?;
let glossary_path = configured_glossary_path
.map(PathBuf::from)
.unwrap_or_else(|| {
bat_infrastructure::SqliteGlossaryRepository::repository_path(
&current.resource_root,
)
});
let glossary = if std::fs::symlink_metadata(&glossary_path).is_ok() {
Some(
bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
.await
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))?,
)
} else {
None
};
let results = build_manual_translation_results( let results = build_manual_translation_results(
&current_task, &current_task,
index, index,
@@ -236,7 +255,9 @@ pub(super) fn update_translation_task_status_report(
&result_provider, &result_provider,
&result_provider_run_id, &result_provider_run_id,
result_timestamp, result_timestamp,
)?; glossary.as_ref(),
)
.await?;
repository repository
.update_status_with_results( .update_status_with_results(
task_id, task_id,
@@ -291,13 +312,14 @@ fn translation_task_result_params(
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}")) .map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
} }
fn build_manual_translation_results( async fn build_manual_translation_results(
task: &bat_infrastructure::PersistedTranslationTask, task: &bat_infrastructure::PersistedTranslationTask,
index: &bat_infrastructure::OfficialTextUnitIndex, index: &bat_infrastructure::OfficialTextUnitIndex,
params: &[TranslationTaskResultUpdateParam], params: &[TranslationTaskResultUpdateParam],
provider: &str, provider: &str,
provider_run_id: &str, provider_run_id: &str,
translated_unix_seconds: u64, translated_unix_seconds: u64,
glossary: Option<&bat_infrastructure::SqliteGlossaryRepository>,
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> { ) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
let index_by_id = index let index_by_id = index
.units .units
@@ -332,6 +354,53 @@ fn build_manual_translation_results(
"TextUnit {unit_id} 的 source_text 与当前索引不一致" "TextUnit {unit_id} 的 source_text 与当前索引不一致"
)); ));
} }
let glossary_qa = if let Some(glossary) = glossary {
let context = bat_infrastructure::translation_memory_context(
&unit.destination,
unit.archive_entry.as_deref(),
unit.serialized_file.as_deref(),
unit.path_id,
unit.class_id,
unit.field_path.as_deref(),
unit.format.as_deref(),
unit.asset_name.as_deref(),
unit.text_source_kind.as_deref(),
&unit.context,
);
Some(
glossary
.diagnose(&unit.source_text, &context)
.await
.map_err(|error| anyhow::anyhow!("Glossary QA 失败:{error}"))?
.check_translation(&param.translated_text),
)
} else {
None
};
if let Some(qa) = glossary_qa.as_ref().filter(|qa| qa.status.is_blocked()) {
let Some(override_record) = param.glossary_override.as_ref() else {
return Err(anyhow::anyhow!(
"TextUnit {} 的 Glossary QA blocked;必须提供 glossary_override",
unit_id
));
};
if override_record.reviewer.trim().is_empty()
|| override_record.reason.trim().is_empty()
|| override_record.provenance.trim().is_empty()
|| override_record.confirmed_unix_seconds == 0
{
return Err(anyhow::anyhow!(
"TextUnit {} 的 glossary_override 不完整或 confirmed_unix_seconds 无效",
unit_id
));
}
let _ = qa;
} else if param.glossary_override.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
unit_id
));
}
results.push(bat_infrastructure::TranslationTaskUnitResult { results.push(bat_infrastructure::TranslationTaskUnitResult {
unit_id: unit_id.to_string(), unit_id: unit_id.to_string(),
source_text: param.source_text.clone(), source_text: param.source_text.clone(),
@@ -341,6 +410,8 @@ fn build_manual_translation_results(
provider: provider.to_string(), provider: provider.to_string(),
provider_run_id: provider_run_id.to_string(), provider_run_id: provider_run_id.to_string(),
translated_unix_seconds, translated_unix_seconds,
glossary_qa,
glossary_override: param.glossary_override.clone(),
}); });
} }
Ok(results) 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"))?; .ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
let (resource_root, release_id) = current_official_release(options)?; let (resource_root, release_id) = current_official_release(options)?;
let workbench = read_translation_workbench(path)?; 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!({ let data = serde_json::json!({
"official_release_id": release_id, "official_release_id": release_id,
"resource_root": resource_root, "resource_root": resource_root,
@@ -141,7 +146,50 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
.translation_id .translation_id
.as_deref() .as_deref()
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?; .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!({ let data = serde_json::json!({
"translation_file": path, "translation_file": path,
"entry": entry, "entry": entry,
@@ -232,6 +280,7 @@ pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Resul
let report = update_translation_task_status_report( let report = update_translation_task_status_report(
&options.state_dir, &options.state_dir,
Some(&serde_json::Value::Object(params)), Some(&serde_json::Value::Object(params)),
options.glossary_path.as_deref(),
)?; )?;
print_json_value(options.output_format, &report) print_json_value(options.output_format, &report)
} }
@@ -316,6 +365,12 @@ pub(super) fn publish_localized_report(
"翻译工作台资源根目录与当前 release 不一致;请重新导出" "翻译工作台资源根目录与当前 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 operations = localized_patch_operations(&resource_root, &workbench)?;
let localized_release_id = options.localized_release_id.clone().or_else(|| { let localized_release_id = options.localized_release_id.clone().or_else(|| {
options options
+757
View File
@@ -0,0 +1,757 @@
//! 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 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 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)?;
let term = self.find(&term_id).await?;
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()
.any(|spelling| source.contains(spelling))
}) {
terms.push(term);
if terms.len() >= limit {
break;
}
}
}
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)?;
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 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
}
/// 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
.query(None, None, Some(GlossaryReviewStatus::Approved), 1000)
.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();
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);
}
}
+10 -3
View File
@@ -13,6 +13,7 @@
pub mod cas; pub mod cas;
mod curl_transfer; mod curl_transfer;
pub mod downloader; pub mod downloader;
pub mod glossary;
pub mod import; pub mod import;
pub mod localized_patch; pub mod localized_patch;
pub mod official_changes; pub mod official_changes;
@@ -43,6 +44,10 @@ pub use downloader::{
DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY, DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY,
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
}; };
pub use glossary::{
SqliteGlossaryRepository, GLOSSARY_REPOSITORY_FILE, GLOSSARY_SCHEMA_COMPONENT,
GLOSSARY_SCHEMA_VERSION,
};
pub use import::{ pub use import::{
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport, BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
ResourceImportService, ResourceImportService,
@@ -169,9 +174,11 @@ pub use translation_workflow::{
completed_worker_translation_workbench, export_completed_worker_translation_workbench, completed_worker_translation_workbench, export_completed_worker_translation_workbench,
export_translation_workbench, get_translation_entry, localized_patch_operations, export_translation_workbench, get_translation_entry, localized_patch_operations,
localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation, localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation,
unset_translation, validate_translation_workbench, write_translation_workbench, set_translation_checked, set_translation_checked_with_glossary_path, unset_translation,
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, validate_translation_workbench, validate_translation_workbench_with_glossary_path,
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION, write_translation_workbench, RepackOperation, RepackReport, RepackSpec, TranslationWorkbench,
TranslationWorkbenchEntry, TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION,
TRANSLATION_WORKBENCH_VERSION,
}; };
/// Infrastructure 版本号 /// Infrastructure 版本号
+6
View File
@@ -99,6 +99,12 @@ pub struct LocalizedPatchOperationMetadata {
pub translation_memory_record_id: Option<String>, pub translation_memory_record_id: Option<String>,
/// Review state used by the publication input. /// Review state used by the publication input.
pub review_status: String, 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. /// Configuration for one localized release publication.
+10
View File
@@ -173,6 +173,12 @@ pub struct TranslationTaskUnitResult {
pub provider_run_id: String, pub provider_run_id: String,
/// Result persistence time. /// Result persistence time.
pub translated_unix_seconds: u64, 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. /// Source of one persisted TextUnit translation result.
@@ -1808,6 +1814,8 @@ mod tests {
provider: "manual".to_string(), provider: "manual".to_string(),
provider_run_id: "manual-run-1".to_string(), provider_run_id: "manual-run-1".to_string(),
translated_unix_seconds: 321, translated_unix_seconds: 321,
glossary_qa: None,
glossary_override: None,
}; };
let updated = repository let updated = repository
@@ -1927,6 +1935,8 @@ mod tests {
provider: "mock".to_string(), provider: "mock".to_string(),
provider_run_id: second_run.clone(), provider_run_id: second_run.clone(),
translated_unix_seconds: 1, translated_unix_seconds: 1,
glossary_qa: None,
glossary_override: None,
}; };
assert!(repository assert!(repository
+306 -8
View File
@@ -5,6 +5,7 @@
//! 租约和任务结果写入 release 级 `translation-tasks.sqlite`,跨 release 的 //! 租约和任务结果写入 release 级 `translation-tasks.sqlite`,跨 release 的
//! Translation Memory 写入项目级独立 SQLite 数据库。 //! Translation Memory 写入项目级独立 SQLite 数据库。
use crate::glossary::SqliteGlossaryRepository;
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit}; use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
use crate::official_textunit_queue::read_textunit_task_queue_at; use crate::official_textunit_queue::read_textunit_task_queue_at;
use crate::translation_memory::{translation_memory_context, SqliteTranslationMemoryRepository}; use crate::translation_memory::{translation_memory_context, SqliteTranslationMemoryRepository};
@@ -14,9 +15,10 @@ use crate::translation_tasks::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use bat_core::domain::{ 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 serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::env; use std::env;
@@ -98,6 +100,9 @@ pub struct TranslationWorkerConfig {
/// Translation Memory SQLite path. `None` uses the output-root default. /// Translation Memory SQLite path. `None` uses the output-root default.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub translation_memory_path: Option<PathBuf>, 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 { impl Default for TranslationWorkerConfig {
@@ -112,6 +117,7 @@ impl Default for TranslationWorkerConfig {
max_tasks: None, max_tasks: None,
worker_id: format!("bat-worker-{}", std::process::id()), worker_id: format!("bat-worker-{}", std::process::id()),
translation_memory_path: None, translation_memory_path: None,
glossary_path: None,
} }
} }
} }
@@ -180,6 +186,9 @@ pub struct TranslationProviderUnit {
/// 解析器保留的上下文,包括可选 `crowdin_string_id`。 /// 解析器保留的上下文,包括可选 `crowdin_string_id`。
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub context: BTreeMap<String, String>, 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 批处理请求。 /// 一次 provider 批处理请求。
@@ -596,6 +605,14 @@ pub struct TranslationWorkerReport {
pub provider_unit_count: usize, pub provider_unit_count: usize,
/// Translation Memory diagnostics that did not invalidate provider work. /// Translation Memory diagnostics that did not invalidate provider work.
pub translation_memory_failures: Vec<String>, 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 失败诊断。 /// worker 失败诊断。
@@ -623,6 +640,8 @@ struct WorkerStats {
provider_unit_count: AtomicUsize, provider_unit_count: AtomicUsize,
failures: Mutex<Vec<TranslationWorkerFailure>>, failures: Mutex<Vec<TranslationWorkerFailure>>,
translation_memory_failures: Mutex<Vec<String>>, translation_memory_failures: Mutex<Vec<String>>,
glossary_blocked_count: AtomicUsize,
glossary_failures: Mutex<Vec<String>>,
} }
struct WorkerTaskContext<'a> { struct WorkerTaskContext<'a> {
@@ -635,6 +654,7 @@ struct WorkerTaskContext<'a> {
retry_backoff: Duration, retry_backoff: Duration,
stats: &'a WorkerStats, stats: &'a WorkerStats,
translation_memory: Option<&'a dyn TranslationMemoryRepository>, translation_memory: Option<&'a dyn TranslationMemoryRepository>,
glossary: Option<&'a dyn GlossaryRepository>,
} }
/// 运行一个 provider worker 轮次。 /// 运行一个 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( let repository = Arc::new(
SqliteTranslationTaskRepository::new(SqliteTranslationTaskRepository::repository_path( SqliteTranslationTaskRepository::new(SqliteTranslationTaskRepository::repository_path(
resource_root, resource_root,
@@ -754,6 +796,7 @@ async fn run_translation_worker_with_provider_and_cancellation(
let lease_seconds = config.lease_seconds; let lease_seconds = config.lease_seconds;
let retry_backoff = config.retry_backoff; let retry_backoff = config.retry_backoff;
let translation_memory = translation_memory.clone(); let translation_memory = translation_memory.clone();
let glossary = glossary.clone();
let should_cancel = Arc::clone(&should_cancel); let should_cancel = Arc::clone(&should_cancel);
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
loop { loop {
@@ -791,6 +834,9 @@ async fn run_translation_worker_with_provider_and_cancellation(
translation_memory: translation_memory translation_memory: translation_memory
.as_deref() .as_deref()
.map(|repository| repository as &dyn TranslationMemoryRepository), .map(|repository| repository as &dyn TranslationMemoryRepository),
glossary: glossary
.as_deref()
.map(|repository| repository as &dyn GlossaryRepository),
}, },
&task, &task,
) )
@@ -842,6 +888,11 @@ async fn run_translation_worker_with_provider_and_cancellation(
.lock() .lock()
.map_err(|_| anyhow::anyhow!("读取 Translation Memory 诊断时 mutex poisoned"))? .map_err(|_| anyhow::anyhow!("读取 Translation Memory 诊断时 mutex poisoned"))?
.clone(); .clone();
let glossary_failures = stats
.glossary_failures
.lock()
.map_err(|_| anyhow::anyhow!("读取 Glossary 诊断时 mutex poisoned"))?
.clone();
Ok(TranslationWorkerReport { Ok(TranslationWorkerReport {
command: "translation-worker", command: "translation-worker",
status: if failed_count == 0 { 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), translation_memory_hit_count: stats.translation_memory_hit_count.load(Ordering::Relaxed),
provider_unit_count: stats.provider_unit_count.load(Ordering::Relaxed), provider_unit_count: stats.provider_unit_count.load(Ordering::Relaxed),
translation_memory_failures, translation_memory_failures,
glossary_path,
glossary_available: glossary.is_some(),
glossary_blocked_count: stats.glossary_blocked_count.load(Ordering::Relaxed),
glossary_failures,
}) })
} }
@@ -873,8 +928,8 @@ async fn process_claimed_task(
let task_units = task_index_units(task, context.index)?; let task_units = task_index_units(task, context.index)?;
let mut results = BTreeMap::new(); let mut results = BTreeMap::new();
let mut provider_units = Vec::new(); let mut provider_units = Vec::new();
let mut glossary_evaluations = BTreeMap::new();
for unit in &task_units { for unit in &task_units {
if let Some(translation_memory) = context.translation_memory {
let source_context = translation_memory_context( let source_context = translation_memory_context(
&unit.destination, &unit.destination,
unit.archive_entry.as_deref(), unit.archive_entry.as_deref(),
@@ -887,23 +942,87 @@ async fn process_claimed_task(
unit.text_source_kind.as_deref(), unit.text_source_kind.as_deref(),
&unit.context, &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 {
match translation_memory match translation_memory
.find_matches(&unit.source_text, &source_context, 1) .find_matches(&unit.source_text, &source_context, 1)
.await .await
{ {
Ok(matches) => { Ok(matches) => {
if let Some(found) = matches.into_iter().find(|item| item.can_auto_reuse) { if let Some(found) = matches.into_iter().find(|item| item.can_auto_reuse) {
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 context
.stats .stats
.translation_memory_hit_count .translation_memory_hit_count
.fetch_add(1, Ordering::Relaxed); .fetch_add(1, Ordering::Relaxed);
results.insert( results.insert(
unit.id.clone(), unit.id.clone(),
translation_memory_result(task, unit, &found.entry), translation_memory_result(task, unit, &found.entry, qa),
); );
continue; continue;
} }
} }
}
Err(error) => { Err(error) => {
record_translation_memory_failure( record_translation_memory_failure(
context, context,
@@ -923,7 +1042,7 @@ async fn process_claimed_task(
.stats .stats
.provider_unit_count .provider_unit_count
.fetch_add(provider_units.len(), Ordering::Relaxed); .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, Ok(request) => request,
Err(error) => { Err(error) => {
record_provider_failure( record_provider_failure(
@@ -941,8 +1060,12 @@ async fn process_claimed_task(
}; };
match context.provider.translate(request.clone()).await { match context.provider.translate(request.clone()).await {
Ok(response) => { Ok(response) => {
let provider_results = let provider_results = match validate_provider_response(
match validate_provider_response(&request, response, context.provider_name) { &request,
response,
context.provider_name,
&glossary_evaluations,
) {
Ok(results) => results, Ok(results) => results,
Err(error) => { Err(error) => {
record_provider_failure( record_provider_failure(
@@ -958,6 +1081,27 @@ async fn process_claimed_task(
return Ok(()); 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 { for result in &provider_results {
results.insert(result.unit_id.clone(), result.clone()); results.insert(result.unit_id.clone(), result.clone());
if let Some(unit) = provider_units.iter().find(|unit| unit.id == result.unit_id) 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(), observed_unix_seconds: unix_seconds_now(),
}; };
if let Some(translation_memory) = context.translation_memory { 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 { if let Err(error) = translation_memory.upsert_candidate(draft).await {
record_translation_memory_failure( record_translation_memory_failure(
context, context,
@@ -1066,6 +1217,7 @@ fn task_index_units<'a>(
fn provider_request( fn provider_request(
task: &PersistedTranslationTask, task: &PersistedTranslationTask,
index_units: &[&OfficialTextUnitIndexUnit], index_units: &[&OfficialTextUnitIndexUnit],
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
) -> anyhow::Result<TranslationProviderRequest> { ) -> anyhow::Result<TranslationProviderRequest> {
let provider_run_id = task let provider_run_id = task
.provider_run_id .provider_run_id
@@ -1079,7 +1231,16 @@ fn provider_request(
archive_entry: task.task.archive_entry.clone(), archive_entry: task.task.archive_entry.clone(),
units: index_units units: index_units
.iter() .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(), .collect(),
}) })
} }
@@ -1087,6 +1248,7 @@ fn provider_request(
fn provider_unit( fn provider_unit(
task: &PersistedTranslationTask, task: &PersistedTranslationTask,
unit: &OfficialTextUnitIndexUnit, unit: &OfficialTextUnitIndexUnit,
glossary_constraints: Vec<GlossaryConstraint>,
) -> TranslationProviderUnit { ) -> TranslationProviderUnit {
TranslationProviderUnit { TranslationProviderUnit {
unit_id: unit.id.clone(), unit_id: unit.id.clone(),
@@ -1103,6 +1265,7 @@ fn provider_unit(
text_source_kind: unit.text_source_kind.clone(), text_source_kind: unit.text_source_kind.clone(),
asset_name: unit.asset_name.clone(), asset_name: unit.asset_name.clone(),
context: unit.context.clone(), context: unit.context.clone(),
glossary_constraints,
} }
} }
@@ -1110,6 +1273,7 @@ fn validate_provider_response(
request: &TranslationProviderRequest, request: &TranslationProviderRequest,
response: TranslationProviderResponse, response: TranslationProviderResponse,
provider_name: &str, provider_name: &str,
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
) -> anyhow::Result<Vec<TranslationTaskUnitResult>> { ) -> anyhow::Result<Vec<TranslationTaskUnitResult>> {
if response.provider_run_id != request.provider_run_id { if response.provider_run_id != request.provider_run_id {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
@@ -1150,6 +1314,9 @@ fn validate_provider_response(
result.unit_id result.unit_id
)); ));
} }
let glossary_qa = glossary_evaluations
.get(&result.unit_id)
.map(|evaluation| evaluation.check_translation(&result.translated_text));
results.push(TranslationTaskUnitResult { results.push(TranslationTaskUnitResult {
unit_id: result.unit_id, unit_id: result.unit_id,
source_text: result.source_text, source_text: result.source_text,
@@ -1159,6 +1326,8 @@ fn validate_provider_response(
provider: provider_name.to_string(), provider: provider_name.to_string(),
provider_run_id: request.provider_run_id.clone(), provider_run_id: request.provider_run_id.clone(),
translated_unix_seconds: unix_seconds_now(), translated_unix_seconds: unix_seconds_now(),
glossary_qa,
glossary_override: None,
}); });
} }
if seen.len() != expected.len() { if seen.len() != expected.len() {
@@ -1175,6 +1344,7 @@ fn translation_memory_result(
task: &PersistedTranslationTask, task: &PersistedTranslationTask,
unit: &OfficialTextUnitIndexUnit, unit: &OfficialTextUnitIndexUnit,
entry: &bat_core::domain::TranslationMemoryEntry, entry: &bat_core::domain::TranslationMemoryEntry,
glossary_qa: GlossaryQaReport,
) -> TranslationTaskUnitResult { ) -> TranslationTaskUnitResult {
TranslationTaskUnitResult { TranslationTaskUnitResult {
unit_id: unit.id.clone(), unit_id: unit.id.clone(),
@@ -1185,6 +1355,8 @@ fn translation_memory_result(
provider: "translation_memory".to_string(), provider: "translation_memory".to_string(),
provider_run_id: task.provider_run_id.clone().unwrap_or_default(), provider_run_id: task.provider_run_id.clone().unwrap_or_default(),
translated_unix_seconds: unix_seconds_now(), translated_unix_seconds: unix_seconds_now(),
glossary_qa: Some(glossary_qa),
glossary_override: None,
} }
} }
@@ -1222,6 +1394,16 @@ fn record_translation_memory_failure(
Ok(()) 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( async fn record_provider_failure(
context: &WorkerTaskContext<'_>, context: &WorkerTaskContext<'_>,
task: &PersistedTranslationTask, task: &PersistedTranslationTask,
@@ -1455,6 +1637,122 @@ mod tests {
assert_eq!(task.translation_results[0].translated_text, "translated-0"); 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::Approved,
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();
#[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] #[tokio::test]
async fn worker_honors_cancellation_before_claiming_tasks() { async fn worker_honors_cancellation_before_claiming_tasks() {
let (temp, queue) = fixture_root(); let (temp, queue) = fixture_root();
+167
View File
@@ -1,5 +1,6 @@
//! Manual translation workbench and controlled UnityFS repack workflows. //! Manual translation workbench and controlled UnityFS repack workflows.
use crate::glossary::SqliteGlossaryRepository;
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit}; use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
use crate::official_textunit_queue::OfficialTextUnitTaskQuery; use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
use crate::path_security::{ use crate::path_security::{
@@ -15,6 +16,7 @@ use bat_assetbundle::{
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue, StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
}; };
use bat_core::domain::{GlossaryOverride, GlossaryQaReport};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -90,6 +92,12 @@ pub struct TranslationWorkbenchEntry {
/// Extraction source kind such as TextAsset or TypeTreeField. /// Extraction source kind such as TextAsset or TypeTreeField.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub text_source_kind: Option<String>, 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`. /// Summary produced by `i18n validate`.
@@ -274,6 +282,73 @@ pub fn set_translation(
.find(|entry| entry.id == entry_id) .find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?; .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = Some(translated_text); entry.translated_text = Some(translated_text);
entry.glossary_qa = None;
entry.glossary_override = None;
let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?;
Ok(updated)
}
/// Updates one translation and evaluates the project Glossary.
pub fn set_translation_checked(
resource_root: &Path,
workbench_path: &Path,
entry_id: &str,
translated_text: String,
glossary_override: Option<GlossaryOverride>,
) -> anyhow::Result<TranslationWorkbenchEntry> {
set_translation_checked_with_glossary_path(
resource_root,
workbench_path,
entry_id,
translated_text,
glossary_override,
None,
)
}
/// Updates one translation using an optional configured Glossary path.
pub fn set_translation_checked_with_glossary_path(
resource_root: &Path,
workbench_path: &Path,
entry_id: &str,
translated_text: String,
glossary_override: Option<GlossaryOverride>,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<TranslationWorkbenchEntry> {
let mut workbench = read_translation_workbench(workbench_path)?;
let current = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?
.units
.into_iter()
.find(|unit| unit.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("当前 release 不存在 TextUnit{entry_id}"))?;
let glossary_path = configured_glossary_path
.map(PathBuf::from)
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
let glossary = open_glossary_if_present(&glossary_path)?;
let qa = glossary
.as_ref()
.map(|glossary| evaluate_glossary_entry(glossary, &current, &translated_text))
.transpose()?;
if let Some(qa) = qa.as_ref().filter(|qa| qa.status.is_blocked()) {
validate_glossary_override(glossary_override.as_ref())?;
let _ = qa;
} else if glossary_override.is_some() {
return Err(anyhow::anyhow!(
"Glossary override 只能用于存在 blocking QA 的译文"
));
}
let entry = workbench
.entries
.iter_mut()
.find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = Some(translated_text);
entry.glossary_qa = qa;
entry.glossary_override = glossary_override;
let updated = entry.clone(); let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now(); workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?; write_translation_workbench(workbench_path, &workbench)?;
@@ -305,6 +380,8 @@ pub fn unset_translation(
.find(|entry| entry.id == entry_id) .find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?; .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = None; entry.translated_text = None;
entry.glossary_qa = None;
entry.glossary_override = None;
let updated = entry.clone(); let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now(); workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?; write_translation_workbench(workbench_path, &workbench)?;
@@ -320,6 +397,21 @@ pub fn validate_translation_workbench(
resource_root: &Path, resource_root: &Path,
official_release_id: &str, official_release_id: &str,
workbench: &TranslationWorkbench, 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> { ) -> anyhow::Result<TranslationWorkbenchValidationReport> {
let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?; let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?;
if workbench.official_release_id != official_release_id { if workbench.official_release_id != official_release_id {
@@ -349,6 +441,10 @@ pub fn validate_translation_workbench(
let mut changed_entries = 0; let mut changed_entries = 0;
let mut publishable_entries = 0; let mut publishable_entries = 0;
let mut repack_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 { for entry in &workbench.entries {
if !seen_ids.insert(entry.id.as_str()) { if !seen_ids.insert(entry.id.as_str()) {
@@ -366,6 +462,12 @@ pub fn validate_translation_workbench(
unchanged_entries += 1; unchanged_entries += 1;
continue; 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; changed_entries += 1;
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref()); let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
let is_publishable = entry.archive_entry.is_none() 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 /// Converts reviewed entries to localized patch operations supported by the
/// current UnityFS write layer. /// current UnityFS write layer.
/// ///
@@ -628,6 +787,8 @@ fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatch
.review_status .review_status
.clone() .clone()
.unwrap_or_else(|| "manual_reviewed".to_string()), .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_source_kind = Some(result.source_kind.as_str().to_string());
entry.translation_memory_record_id = result.translation_memory_record_id.clone(); entry.translation_memory_record_id = result.translation_memory_record_id.clone();
entry.translated_unix_seconds = Some(result.translated_unix_seconds); 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( entry.review_status = Some(
match result.source_kind { match result.source_kind {
TranslationTaskResultSourceKind::Provider => "provider_completed", TranslationTaskResultSourceKind::Provider => "provider_completed",
@@ -964,6 +1127,8 @@ impl TranslationWorkbenchEntry {
review_status: None, review_status: None,
format: unit.format.clone(), format: unit.format.clone(),
text_source_kind: unit.text_source_kind.clone(), text_source_kind: unit.text_source_kind.clone(),
glossary_qa: None,
glossary_override: None,
} }
} }
} }
@@ -1003,6 +1168,8 @@ mod tests {
review_status: None, review_status: None,
format: Some("plain".to_string()), format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()), text_source_kind: Some("text_asset".to_string()),
glossary_qa: None,
glossary_override: None,
}], }],
} }
} }
+206
View File
@@ -56,6 +56,9 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/translation/handoff", "/admin/translation/handoff",
"/admin/translation/memory/summary", "/admin/translation/memory/summary",
"/admin/translation/memory/query", "/admin/translation/memory/query",
"/admin/translation/glossary/summary",
"/admin/translation/glossary/query",
"/admin/translation/glossary/diagnose",
"/admin/translation/status", "/admin/translation/status",
}, },
Controls: []string{ Controls: []string{
@@ -75,6 +78,10 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/translation-worker-run", "/admin/control/translation-worker-run",
"/admin/control/translation-proofread", "/admin/control/translation-proofread",
"/admin/control/translation-memory-confirm", "/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/localized-publish", "/admin/control/localized-publish",
"/admin/control/localized-rollback", "/admin/control/localized-rollback",
}, },
@@ -124,6 +131,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminTranslationMemoryConfirm(w, r) s.handleAdminTranslationMemoryConfirm(w, r)
return return
} }
if strings.HasPrefix(action, "translation-glossary-") {
s.handleAdminGlossaryControl(w, r, action)
return
}
if action == "localized-publish" { if action == "localized-publish" {
s.handleAdminLocalizedPublish(w, r) s.handleAdminLocalizedPublish(w, r)
return return
@@ -309,6 +320,201 @@ func (s *Server) handleAdminTranslationMemoryConfirm(w http.ResponseWriter, r *h
}) })
} }
func (s *Server) handleAdminGlossarySummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
params := backendrpc.GlossarySummaryParams{
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
}
result, err := backend.GlossarySummary(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-glossary-summary", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminGlossaryQuery(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
params := backendrpc.GlossaryQueryParams{
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"),
Category: firstTrimmedQuery(r.URL.Query(), "category"),
ReviewStatus: firstTrimmedQuery(r.URL.Query(), "review_status"),
}
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
limit, err := strconv.ParseUint(raw, 10, 64)
if err != nil || limit == 0 || limit > 1000 {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "limit must be in 1..=1000")
return
}
params.Limit = &limit
}
result, err := backend.GlossaryQuery(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-glossary-query", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminGlossaryDiagnose(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
params := backendrpc.GlossaryDiagnoseParams{
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"),
}
if params.SourceText == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "source_text is required")
return
}
if raw := firstTrimmedQuery(r.URL.Query(), "context", "source_context"); raw != "" {
if err := json.Unmarshal([]byte(raw), &params.Context); err != nil || params.Context == nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "context must be a JSON object with string values")
return
}
}
result, err := backend.GlossaryDiagnose(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "translation-glossary-diagnose", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminGlossaryControl(w http.ResponseWriter, r *http.Request, action string) {
backend, ok := s.backend.(GlossaryBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
return
}
switch action {
case "translation-glossary-add", "translation-glossary-update":
var params backendrpc.GlossaryTermMutationParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.SourceTerm) == "" ||
strings.TrimSpace(params.RecommendedTranslation) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, source_term and recommended_translation are required")
return
}
var result *backendrpc.GlossaryMutationReport
var err error
if action == "translation-glossary-add" {
result, err = backend.GlossaryAdd(r.Context(), params)
} else {
if strings.TrimSpace(params.Reviewer) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "reviewer is required for update")
return
}
result, err = backend.GlossaryUpdate(r.Context(), params)
}
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: glossaryControlRPCMethod(action),
Status: "accepted",
Result: result,
})
case "translation-glossary-approve", "translation-glossary-deprecate":
var params backendrpc.GlossaryReviewParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.Reviewer) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id and reviewer are required")
return
}
var result *backendrpc.GlossaryMutationReport
var err error
if action == "translation-glossary-approve" {
result, err = backend.GlossaryApprove(r.Context(), params)
} else {
result, err = backend.GlossaryDeprecate(r.Context(), params)
}
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: glossaryControlRPCMethod(action),
Status: "accepted",
Result: result,
})
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"
default:
return ""
}
}
func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(LocalizedBackend) backend, ok := s.backend.(LocalizedBackend)
if !ok || backend == nil { if !ok || backend == nil {
+133
View File
@@ -637,6 +637,11 @@ type controlBackend struct {
translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams
translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams
translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams
glossarySummaryParams []backendrpc.GlossarySummaryParams
glossaryQueryParams []backendrpc.GlossaryQueryParams
glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams
glossaryMutationParams []backendrpc.GlossaryTermMutationParams
glossaryReviewParams []backendrpc.GlossaryReviewParams
localizedPublishParams []backendrpc.LocalizedPublishParams localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams localizedRollbackParams []backendrpc.LocalizedRollbackParams
} }
@@ -825,6 +830,81 @@ func (b *controlBackend) TranslationMemoryConfirm(ctx context.Context, params ba
}, nil }, 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) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "localized.status") b.calls = append(b.calls, "localized.status")
return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil
@@ -1044,6 +1124,48 @@ func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T)
t.Fatalf("missing TM query source status=%d body=%s", recorder.Code, recorder.Body.String()) 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 = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks?limit=0", nil)
request.Header.Set("Authorization", "Bearer translation-token") request.Header.Set("Authorization", "Bearer translation-token")
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
@@ -1818,6 +1940,10 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{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 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 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 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: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"}, {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"}, {name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
} }
@@ -1854,6 +1980,13 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" { backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" {
t.Fatalf("TM confirm params=%#v", backend.translationMemoryConfirmParams) 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" {
t.Fatalf("Glossary params mutation=%#v review=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams)
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`)) request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
request.Header.Set("Authorization", "Bearer control-token") request.Header.Set("Authorization", "Bearer control-token")
+41
View File
@@ -198,3 +198,44 @@ func TestTranslationMemoryRustContractMirror(t *testing.T) {
t.Fatalf("missing TM summary=%+v", missing) 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)
}
}
+130 -1
View File
@@ -461,6 +461,86 @@ paths:
description: Missing or invalid admin token. description: Missing or invalid admin token.
"503": "503":
description: Rust bat Translation Memory backend is unavailable. 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: /admin/translation/status:
get: get:
summary: Read Rust-owned localized release status summary: Read Rust-owned localized release status
@@ -480,7 +560,7 @@ paths:
required: true required: true
schema: schema:
type: string 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, localized-publish, localized-rollback]
requestBody: requestBody:
required: false required: false
content: content:
@@ -573,8 +653,57 @@ paths:
type: string type: string
translation_memory_path: translation_memory_path:
type: string type: string
glossary_path:
type: string
record_id: record_id:
type: string 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: reviewer:
type: string type: string
reason: reason:
+34
View File
@@ -92,6 +92,18 @@ type TranslationMemoryBackend interface {
TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error) 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)
}
// LocalizedBackend exposes localized release status and the explicit // LocalizedBackend exposes localized release status and the explicit
// publish/rollback controls used by the authenticated dashboard. // publish/rollback controls used by the authenticated dashboard.
type LocalizedBackend interface { type LocalizedBackend interface {
@@ -203,6 +215,28 @@ func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendr
return r.Client.TranslationMemoryConfirm(ctx, params) 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) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.LocalizedStatus(ctx) return r.Client.LocalizedStatus(ctx)
} }
+6
View File
@@ -76,6 +76,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff) mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary) mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary)
mux.HandleFunc("/admin/translation/memory/query", s.handleAdminTranslationMemoryQuery) 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/translation/status", s.handleAdminLocalizedStatus)
mux.HandleFunc("/admin/control/", s.handleAdminControl) mux.HandleFunc("/admin/control/", s.handleAdminControl)
mux.HandleFunc("/admin/", s.handleAdminIndex) mux.HandleFunc("/admin/", s.handleAdminIndex)
@@ -155,6 +158,9 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/admin/translation/handoff", "/admin/translation/handoff",
"/admin/translation/memory/summary", "/admin/translation/memory/summary",
"/admin/translation/memory/query", "/admin/translation/memory/query",
"/admin/translation/glossary/summary",
"/admin/translation/glossary/query",
"/admin/translation/glossary/diagnose",
"/admin/translation/status", "/admin/translation/status",
"/admin/control/{action}", "/admin/control/{action}",
}, },
+1
View File
@@ -10,6 +10,7 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本
- `resource.manifest` 第一页分页响应。 - `resource.manifest` 第一页分页响应。
- 对应 release 的 `official-sync-snapshot.json` - 对应 release 的 `official-sync-snapshot.json`
- `launcher_metadata``game_main_config_bootstrap` 的 Go mirror 解码。 - `launcher_metadata``game_main_config_bootstrap` 的 Go mirror 解码。
- Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live 这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live
daemon socket 或完整发布切换验证。 daemon socket 或完整发布切换验证。
+99
View File
@@ -0,0 +1,99 @@
{
"available": true,
"path": "${GLOSSARY_PATH}",
"source_text": "${SOURCE_TEXT}",
"terms": [
{
"term_id": "${TERM_ID}",
"source_term": "${SOURCE_TERM}",
"aliases": [
"${ALIAS}"
],
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
"allowed_translations": [
"${ALLOWED_TRANSLATION}"
],
"source_language": "en",
"target_language": "zh-Hans",
"category": "character",
"priority": 10,
"scope": {
"destination": "${DESTINATION}"
},
"review_status": "approved",
"source": {
"source_kind": "manual",
"source_ref": "${SOURCE_REF}",
"source_author": "${SOURCE_AUTHOR}",
"source_note": "${SOURCE_NOTE}",
"observed_unix_seconds": 100
},
"history": [
{
"history_id": "${HISTORY_CREATED_ID}",
"action": "created",
"source": {
"source_kind": "manual",
"source_ref": "${SOURCE_REF}",
"source_author": "${SOURCE_AUTHOR}",
"source_note": "${SOURCE_NOTE}",
"observed_unix_seconds": 100
},
"review_status": "draft",
"snapshot": {
"source_term": "${SOURCE_TERM}",
"aliases": [
"${ALIAS}"
],
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
"allowed_translations": [
"${ALLOWED_TRANSLATION}"
],
"source_language": "en",
"target_language": "zh-Hans",
"category": "character",
"priority": 10,
"scope": {
"destination": "${DESTINATION}"
}
},
"observed_unix_seconds": 100
},
{
"history_id": "${HISTORY_APPROVED_ID}",
"action": "approved",
"reviewer": "${REVIEWER}",
"reason": "reviewed",
"source": {
"source_kind": "manual",
"source_ref": "${SOURCE_REF}",
"source_author": "${SOURCE_AUTHOR}",
"source_note": "${SOURCE_NOTE}",
"observed_unix_seconds": 100
},
"review_status": "approved",
"snapshot": {
"source_term": "${SOURCE_TERM}",
"aliases": [
"${ALIAS}"
],
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
"allowed_translations": [
"${ALLOWED_TRANSLATION}"
],
"source_language": "en",
"target_language": "zh-Hans",
"category": "character",
"priority": 10,
"scope": {
"destination": "${DESTINATION}"
}
},
"observed_unix_seconds": 101
}
],
"created_unix_seconds": 100,
"updated_unix_seconds": 101
}
]
}
+193
View File
@@ -270,6 +270,7 @@ type TranslationTaskUnitResultParam struct {
UnitID string `json:"unit_id"` UnitID string `json:"unit_id"`
SourceText string `json:"source_text"` SourceText string `json:"source_text"`
TranslatedText string `json:"translated_text"` TranslatedText string `json:"translated_text"`
GlossaryOverride *GlossaryOverride `json:"glossary_override,omitempty"`
} }
// TranslationTaskUpdateParams is used by translation.task.update to persist // TranslationTaskUpdateParams is used by translation.task.update to persist
@@ -310,6 +311,7 @@ type TranslationWorkerRunParams struct {
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
FixturePath string `json:"fixture_path,omitempty"` FixturePath string `json:"fixture_path,omitempty"`
TranslationMemoryPath string `json:"translation_memory_path,omitempty"` TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
GlossaryPath string `json:"glossary_path,omitempty"`
Concurrency *uint64 `json:"concurrency,omitempty"` Concurrency *uint64 `json:"concurrency,omitempty"`
MaxAttempts *uint64 `json:"max_attempts,omitempty"` MaxAttempts *uint64 `json:"max_attempts,omitempty"`
LeaseSeconds *uint64 `json:"lease_seconds,omitempty"` LeaseSeconds *uint64 `json:"lease_seconds,omitempty"`
@@ -323,6 +325,7 @@ type TranslationWorkerConfig struct {
Provider string `json:"provider"` Provider string `json:"provider"`
FixturePath string `json:"fixture_path,omitempty"` FixturePath string `json:"fixture_path,omitempty"`
TranslationMemoryPath string `json:"translation_memory_path,omitempty"` TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
GlossaryPath string `json:"glossary_path,omitempty"`
Concurrency uint64 `json:"concurrency"` Concurrency uint64 `json:"concurrency"`
MaxAttempts uint64 `json:"max_attempts"` MaxAttempts uint64 `json:"max_attempts"`
LeaseSeconds uint64 `json:"lease_seconds"` LeaseSeconds uint64 `json:"lease_seconds"`
@@ -476,6 +479,154 @@ type TranslationMemoryConfirmReport struct {
Entry TranslationMemoryEntry `json:"entry"` 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 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"`
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 // LocalizedPublishParams selects the source of one localized release
// publication. TranslationFile and FromWorker are mutually exclusive. // publication. TranslationFile and FromWorker are mutually exclusive.
type LocalizedPublishParams struct { type LocalizedPublishParams struct {
@@ -751,6 +902,48 @@ func (c *Client) TranslationMemoryConfirm(ctx context.Context, params Translatio
return &out, err 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) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) { func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) {
return c.rawData(ctx, "localized.publish", params) return c.rawData(ctx, "localized.publish", params)
} }
+173
View File
@@ -823,6 +823,179 @@ func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
} }
} }
func TestGlossaryTypedContract(t *testing.T) {
limit := uint64(20)
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
switch req.Method {
case "translation.glossary.summary":
var params GlossarySummaryParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode summary params: %v", err)
}
if params.GlossaryPath != "/var/lib/bat/glossary.sqlite" {
t.Fatalf("summary params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-summary",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"schema_version": 1,
"summary": map[string]any{
"schema_version": 1,
"term_count": 2,
"approved_count": 1,
"draft_count": 1,
},
},
}}
case "translation.glossary.query":
var params GlossaryQueryParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode query params: %v", err)
}
if params.SourceText != "Sensei" ||
params.GlossaryPath != "/var/lib/bat/glossary.sqlite" ||
params.Limit == nil || *params.Limit != limit {
t.Fatalf("query params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-query",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"terms": []any{map[string]any{
"term_id": "term-sensei",
"source_term": "Sensei",
"recommended_translation": "老师",
"priority": 10,
"review_status": "approved",
"source": map[string]any{
"source_kind": "manual",
"observed_unix_seconds": 100,
},
}},
},
}}
case "translation.glossary.diagnose":
var params GlossaryDiagnoseParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode diagnose params: %v", err)
}
if params.SourceText != "Sensei" || params.Context["destination"] != "Bundle/dialogue.bundle" {
t.Fatalf("diagnose params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-diagnose",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"source_text": "Sensei",
"evaluation": map[string]any{
"constraints": []any{map[string]any{
"term_id": "term-sensei",
"matched_source": "Sensei",
"recommended_translation": "老师",
}},
"diagnostics": []any{},
"blocked": false,
},
},
}}
case "translation.glossary.add":
var params GlossaryTermMutationParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode add params: %v", err)
}
if params.TermID != "term-sensei" || params.Source.SourceKind != "manual" ||
params.Source.ObservedUnixSeconds != 100 {
t.Fatalf("add params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-add",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{
"term_id": "term-sensei",
"source_term": "Sensei",
"recommended_translation": "老师",
"review_status": "draft",
"source": map[string]any{
"source_kind": "manual",
"observed_unix_seconds": 100,
},
},
},
}}
case "translation.glossary.approve":
var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode approve params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" {
t.Fatalf("approve params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-approve",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{"term_id": "term-sensei", "review_status": "approved"},
},
}}
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)
}
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)
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) { func TestApplicationErrorReturnsAPIError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "task.status" { if req.Method != "task.status" {