mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
feat(glossary): 实现 Rust Glossary V1
This commit is contained in:
+12
-6
@@ -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 都执行确定性 QA;blocking deviation 必须携带
|
||||||
|
reviewer/reason/provenance 的显式 override。`translation.glossary.*` 已通过
|
||||||
|
`bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -88,7 +94,7 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成
|
|||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- 领域服务模块仍为空。
|
- 领域服务模块仍为空。
|
||||||
- Glossary、Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
|
- Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
|
||||||
- 公共错误模型需要与 CLI/API 错误码统一。
|
- 公共错误模型需要与 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 fixture,Go mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移;TM 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text 和 provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
|
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest`、`official-sync-snapshot.json` 和 Glossary query contract fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
|
||||||
|
|
||||||
生产要求:
|
生产要求:
|
||||||
|
|
||||||
@@ -299,7 +305,7 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
|
|||||||
|
|
||||||
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
|
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
|
||||||
2. 继续通用 Patch:manifest 驱动、双 release 查询和清理策略。
|
2. 继续通用 Patch:manifest 驱动、双 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
@@ -261,8 +261,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
1. Translation Memory V1 已使用项目级 SQLite schema:source raw/hash、translation、完整 context、candidate/trusted 和 provenance。
|
1. Translation Memory V1 已使用项目级 SQLite schema:source 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 confirm;Glossary 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。
|
||||||
|
|||||||
@@ -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 MVP(G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation/TM admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
|
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation/TM admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
|
||||||
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。
|
- 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 V1;TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。Glossary、模糊匹配和完整 Provider 扩展体系仍待实现。
|
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库、Translation Memory V1 和 Glossary V1;Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。
|
||||||
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。
|
- `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
@@ -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 内冲突会 blocked,priority、scope specificity、
|
||||||
|
匹配长度和 term ID 使用确定性排序。允许但非推荐译法只产生 warning,系统不会在译文
|
||||||
|
生成后自动替换文本。provider、TM、人工 task update、workbench 和 localized publish
|
||||||
|
均执行相同 QA;blocking deviation 需要 `reviewer`、`reason`、`provenance` 和确认时间
|
||||||
|
组成显式 override。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 退出码
|
## 4. 退出码
|
||||||
|
|||||||
+130
-1
@@ -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:
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 reuse,candidate 必须显式 confirm;Glossary、
|
SQLite,按 raw source + 完整 context 做 trusted exact reuse,candidate 必须显式 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 MVP;Rust `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 V1,Web 术语协作视图仍待建设**;
|
||||||
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
|
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
|
|||||||
当前开发优先推进:
|
当前开发优先推进:
|
||||||
|
|
||||||
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
|
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。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 扩展的开发验证,不能作为当前
|
||||||
资源同步或资源分发的部署前置条件。
|
资源同步或资源分发的部署前置条件。
|
||||||
|
|
||||||
### 远程开发连接
|
### 远程开发连接
|
||||||
|
|||||||
@@ -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 为 trusted;worker 之后才可自动复用。 |
|
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | 显式确认一条 candidate 为 trusted;worker 之后才可自动复用。 |
|
||||||
|
| `translation.glossary.summary` | 已实现 | 可选 `{ "glossary_path": "..." }` | 返回 Glossary schema 版本和 draft/approved/deprecated/rejected 计数;缺库只返回 `available=false`,不会创建空库。 |
|
||||||
|
| `translation.glossary.query` | 已实现 | `{ "source_text": "...", "category": "...", "review_status": "approved", "limit": 100 }` | 查询 term、alias、scope、source provenance 和完整 source/review history。 |
|
||||||
|
| `translation.glossary.diagnose` | 已实现 | `{ "source_text": "...", "context": {...} }` | 只对 approved term 生成 provider-neutral constraints,并返回冲突/覆盖诊断和 blocked 决策。 |
|
||||||
|
| `translation.glossary.add` | 已实现 | Glossary term draft,包含 `term_id`、`source_term`、`recommended_translation`、`source` 等 | Rust 创建 draft/import term 并记录 source history。 |
|
||||||
|
| `translation.glossary.update` | 已实现 | term draft + `reviewer`,可选 `reason` | Rust 替换 term definition,并记录 source/review history。 |
|
||||||
|
| `translation.glossary.approve` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 将 term 明确置为 approved;只有 approved term 进入 worker/TM 自动流程。 |
|
||||||
|
| `translation.glossary.deprecate` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 保留历史但停止自动应用。 |
|
||||||
|
|
||||||
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;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法
|
||||||
|
产生 warning;blocking 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 或临时目录,但不能依赖开发机真实资源目录。
|
||||||
|
|
||||||
|
|||||||
@@ -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-013:Glossary 未实现
|
### G-013:Glossary V1 已实现,协作视图仍缺失
|
||||||
|
|
||||||
需要支持术语优先级、别名、分类、冲突检测和审核。
|
Rust `bat` 已提供独立项目级 `glossary.sqlite`:term/alias/recommended/allowed/category/priority、全局与 TextUnit scope、source history、approved review、冲突诊断、provider-neutral constraints 和确定性 QA 均由 Rust 持有。trusted TM 复用会先经过 Glossary QA;provider、TM、人工 task/workbench 结果都记录 QA,blocking deviation 必须显式提交 reviewer/reason/provenance。`translation.glossary.*` 已通过 `bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图和更丰富的导入/搜索能力。
|
||||||
|
|
||||||
### G-014:完整 Provider 扩展体系未实现
|
### 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。
|
||||||
|
|||||||
@@ -95,8 +95,8 @@
|
|||||||
| 组件 | 路径 | 状态 | 说明 |
|
| 组件 | 路径 | 状态 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
||||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
|
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
|
||||||
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM admin forwarding + 内嵌 dashboard + `.env` |
|
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
|
||||||
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||||
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ use bat_infrastructure::{
|
|||||||
localized_patch_operations, open_append_file, read_download_manifest_at, read_file_no_symlink,
|
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)
|
||||||
|
|||||||
@@ -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(¶ms, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||||
|
let category = glossary_string(¶ms, "category", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||||
|
let review_status = glossary_string(¶ms, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||||
|
let limit = glossary_limit(¶ms, RPC_METHOD_GLOSSARY_QUERY)?;
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
Some(&serde_json::Value::Object(params.clone())),
|
||||||
|
RPC_METHOD_GLOSSARY_QUERY,
|
||||||
|
)?;
|
||||||
|
build_glossary_query_report(&path, source_text, category, review_status, limit)
|
||||||
|
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_QUERY, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_diagnose_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
|
||||||
|
let source_text = glossary_string(¶ms, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
|
||||||
|
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
|
||||||
|
let context = glossary_context(¶ms, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
Some(&serde_json::Value::Object(params.clone())),
|
||||||
|
RPC_METHOD_GLOSSARY_DIAGNOSE,
|
||||||
|
)?;
|
||||||
|
build_glossary_diagnose_report(&path, source_text, context)
|
||||||
|
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DIAGNOSE, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_mutation_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
update: bool,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let method = if update {
|
||||||
|
RPC_METHOD_GLOSSARY_UPDATE
|
||||||
|
} else {
|
||||||
|
RPC_METHOD_GLOSSARY_ADD
|
||||||
|
};
|
||||||
|
let params = glossary_params(params, method)?;
|
||||||
|
let draft: GlossaryTermDraft =
|
||||||
|
serde_json::from_value(serde_json::Value::Object(params.clone())).map_err(|error| {
|
||||||
|
glossary_invalid(method, format!("Glossary term 参数无效:{error}"))
|
||||||
|
})?;
|
||||||
|
let reviewer = glossary_string(¶ms, "reviewer", method)?;
|
||||||
|
if update && reviewer.is_none() {
|
||||||
|
return Err(glossary_invalid(method, "update 缺少 reviewer"));
|
||||||
|
}
|
||||||
|
let reason = glossary_string(¶ms, "reason", method)?.map(str::to_string);
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
Some(&serde_json::Value::Object(params.clone())),
|
||||||
|
method,
|
||||||
|
)?;
|
||||||
|
build_glossary_mutation_report(&path, &draft, update, reviewer, reason)
|
||||||
|
.map_err(|error| glossary_internal_error(method, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_review_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
status: GlossaryReviewStatus,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let method = if status == GlossaryReviewStatus::Approved {
|
||||||
|
RPC_METHOD_GLOSSARY_APPROVE
|
||||||
|
} else {
|
||||||
|
RPC_METHOD_GLOSSARY_DEPRECATE
|
||||||
|
};
|
||||||
|
let params = glossary_params(params, method)?;
|
||||||
|
let term_id = glossary_string(¶ms, "term_id", method)?
|
||||||
|
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
|
||||||
|
let reviewer = glossary_string(¶ms, "reviewer", method)?
|
||||||
|
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
|
||||||
|
let reason = glossary_string(¶ms, "reason", method)?.map(str::to_string);
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
Some(&serde_json::Value::Object(params.clone())),
|
||||||
|
method,
|
||||||
|
)?;
|
||||||
|
build_glossary_review_report(&path, term_id, status, reviewer, reason)
|
||||||
|
.map_err(|error| glossary_internal_error(method, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
¤t.resource_root,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let glossary = if std::fs::symlink_metadata(&glossary_path).is_ok() {
|
||||||
|
Some(
|
||||||
|
bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let results = build_manual_translation_results(
|
let results = build_manual_translation_results(
|
||||||
¤t_task,
|
¤t_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(¶m.translated_text),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(qa) = glossary_qa.as_ref().filter(|qa| qa.status.is_blocked()) {
|
||||||
|
let Some(override_record) = param.glossary_override.as_ref() else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 的 Glossary QA blocked;必须提供 glossary_override",
|
||||||
|
unit_id
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if override_record.reviewer.trim().is_empty()
|
||||||
|
|| override_record.reason.trim().is_empty()
|
||||||
|
|| override_record.provenance.trim().is_empty()
|
||||||
|
|| override_record.confirmed_unix_seconds == 0
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 的 glossary_override 不完整或 confirmed_unix_seconds 无效",
|
||||||
|
unit_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let _ = qa;
|
||||||
|
} else if param.glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
|
||||||
|
unit_id
|
||||||
|
));
|
||||||
|
}
|
||||||
results.push(bat_infrastructure::TranslationTaskUnitResult {
|
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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 版本号
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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, ¤t, &translated_text))
|
||||||
|
.transpose()?;
|
||||||
|
if let Some(qa) = qa.as_ref().filter(|qa| qa.status.is_blocked()) {
|
||||||
|
validate_glossary_override(glossary_override.as_ref())?;
|
||||||
|
let _ = qa;
|
||||||
|
} else if glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Glossary override 只能用于存在 blocking QA 的译文"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let entry = workbench
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|entry| entry.id == entry_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
||||||
|
entry.translated_text = Some(translated_text);
|
||||||
|
entry.glossary_qa = qa;
|
||||||
|
entry.glossary_override = glossary_override;
|
||||||
let updated = entry.clone();
|
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,
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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), ¶ms.Context); err != nil || params.Context == nil {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "context must be a JSON object with string values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := backend.GlossaryDiagnose(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, "translation-glossary-diagnose", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminGlossaryControl(w http.ResponseWriter, r *http.Request, action string) {
|
||||||
|
backend, ok := s.backend.(GlossaryBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "translation-glossary-add", "translation-glossary-update":
|
||||||
|
var params backendrpc.GlossaryTermMutationParams
|
||||||
|
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.SourceTerm) == "" ||
|
||||||
|
strings.TrimSpace(params.RecommendedTranslation) == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, source_term and recommended_translation are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var result *backendrpc.GlossaryMutationReport
|
||||||
|
var err error
|
||||||
|
if action == "translation-glossary-add" {
|
||||||
|
result, err = backend.GlossaryAdd(r.Context(), params)
|
||||||
|
} else {
|
||||||
|
if strings.TrimSpace(params.Reviewer) == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "reviewer is required for update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err = backend.GlossaryUpdate(r.Context(), params)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||||
|
Service: "bat-api",
|
||||||
|
Action: action,
|
||||||
|
RPCMethod: glossaryControlRPCMethod(action),
|
||||||
|
Status: "accepted",
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
case "translation-glossary-approve", "translation-glossary-deprecate":
|
||||||
|
var params backendrpc.GlossaryReviewParams
|
||||||
|
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.Reviewer) == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id and reviewer are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var result *backendrpc.GlossaryMutationReport
|
||||||
|
var err error
|
||||||
|
if action == "translation-glossary-approve" {
|
||||||
|
result, err = backend.GlossaryApprove(r.Context(), params)
|
||||||
|
} else {
|
||||||
|
result, err = backend.GlossaryDeprecate(r.Context(), params)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||||
|
Service: "bat-api",
|
||||||
|
Action: action,
|
||||||
|
RPCMethod: glossaryControlRPCMethod(action),
|
||||||
|
Status: "accepted",
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 或完整发布切换验证。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode summary params: %v", err)
|
||||||
|
}
|
||||||
|
if params.GlossaryPath != "/var/lib/bat/glossary.sqlite" {
|
||||||
|
t.Fatalf("summary params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-summary",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"schema_version": 1,
|
||||||
|
"summary": map[string]any{
|
||||||
|
"schema_version": 1,
|
||||||
|
"term_count": 2,
|
||||||
|
"approved_count": 1,
|
||||||
|
"draft_count": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.query":
|
||||||
|
var params GlossaryQueryParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode query params: %v", err)
|
||||||
|
}
|
||||||
|
if params.SourceText != "Sensei" ||
|
||||||
|
params.GlossaryPath != "/var/lib/bat/glossary.sqlite" ||
|
||||||
|
params.Limit == nil || *params.Limit != limit {
|
||||||
|
t.Fatalf("query params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-query",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"terms": []any{map[string]any{
|
||||||
|
"term_id": "term-sensei",
|
||||||
|
"source_term": "Sensei",
|
||||||
|
"recommended_translation": "老师",
|
||||||
|
"priority": 10,
|
||||||
|
"review_status": "approved",
|
||||||
|
"source": map[string]any{
|
||||||
|
"source_kind": "manual",
|
||||||
|
"observed_unix_seconds": 100,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.diagnose":
|
||||||
|
var params GlossaryDiagnoseParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode diagnose params: %v", err)
|
||||||
|
}
|
||||||
|
if params.SourceText != "Sensei" || params.Context["destination"] != "Bundle/dialogue.bundle" {
|
||||||
|
t.Fatalf("diagnose params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-diagnose",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"source_text": "Sensei",
|
||||||
|
"evaluation": map[string]any{
|
||||||
|
"constraints": []any{map[string]any{
|
||||||
|
"term_id": "term-sensei",
|
||||||
|
"matched_source": "Sensei",
|
||||||
|
"recommended_translation": "老师",
|
||||||
|
}},
|
||||||
|
"diagnostics": []any{},
|
||||||
|
"blocked": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.add":
|
||||||
|
var params GlossaryTermMutationParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode add params: %v", err)
|
||||||
|
}
|
||||||
|
if params.TermID != "term-sensei" || params.Source.SourceKind != "manual" ||
|
||||||
|
params.Source.ObservedUnixSeconds != 100 {
|
||||||
|
t.Fatalf("add params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-add",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"term": map[string]any{
|
||||||
|
"term_id": "term-sensei",
|
||||||
|
"source_term": "Sensei",
|
||||||
|
"recommended_translation": "老师",
|
||||||
|
"review_status": "draft",
|
||||||
|
"source": map[string]any{
|
||||||
|
"source_kind": "manual",
|
||||||
|
"observed_unix_seconds": 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.approve":
|
||||||
|
var params GlossaryReviewParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode approve params: %v", err)
|
||||||
|
}
|
||||||
|
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" {
|
||||||
|
t.Fatalf("approve params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-approve",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"term": map[string]any{"term_id": "term-sensei", "review_status": "approved"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
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" {
|
||||||
|
|||||||
Reference in New Issue
Block a user