From 94483ff14dd1aa24d7046d9741472cef918b44bf Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Mon, 7 Sep 2026 22:38:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(glossary):=20=E5=AE=9E=E7=8E=B0=20Rust=20G?= =?UTF-8?q?lossary=20V1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CURRENT_STATUS.md | 18 +- PROJECT_PLAN.md | 12 +- README.md | 8 +- USERGUIDE.md | 37 +- api/openapi/bat-api.yaml | 131 ++- core/src/domain/glossary.rs | 690 ++++++++++++++++ core/src/domain/mod.rs | 7 + core/src/repositories/glossary_repository.rs | 15 + core/src/repositories/mod.rs | 2 + docs/architecture/README.md | 13 +- ...4-rust-bat-go-bat-api-resource-boundary.md | 2 +- docs/guides/baseline.md | 2 +- docs/guides/bat-workflows.md | 16 + docs/guides/deployment.md | 4 +- docs/reference/rpc-backend-api.md | 31 +- .../BAT_API_CONTRACT_FIXTURE_HANDOFF.md | 4 + docs/reports/CURRENT_GAPS.md | 6 +- docs/reports/GO_STATUS.md | 4 +- infrastructure/src/bin/bat/app.rs | 356 +++++++- infrastructure/src/bin/bat/app_tests.rs | 105 +++ infrastructure/src/bin/bat/config_file.rs | 9 + infrastructure/src/bin/bat/glossary_query.rs | 649 +++++++++++++++ infrastructure/src/bin/bat/report_output.rs | 6 + infrastructure/src/bin/bat/terminal_output.rs | 14 + .../src/bin/bat/translation_query.rs | 77 +- .../src/bin/bat/workflow_commands.rs | 59 +- infrastructure/src/glossary.rs | 757 ++++++++++++++++++ infrastructure/src/lib.rs | 13 +- infrastructure/src/localized_patch.rs | 6 + infrastructure/src/translation_tasks.rs | 10 + infrastructure/src/translation_worker.rs | 382 ++++++++- infrastructure/src/translation_workflow.rs | 167 ++++ internal/api/admin.go | 206 +++++ internal/api/api_test.go | 133 +++ internal/api/contract_fixture_test.go | 41 + internal/api/openapi.go | 131 ++- internal/api/rpc_release.go | 34 + internal/api/server.go | 6 + internal/api/testdata/contract/README.md | 1 + .../api/testdata/contract/glossary-query.json | 99 +++ internal/backendrpc/client.go | 199 ++++- internal/backendrpc/client_test.go | 173 ++++ 42 files changed, 4543 insertions(+), 92 deletions(-) create mode 100644 core/src/domain/glossary.rs create mode 100644 core/src/repositories/glossary_repository.rs create mode 100644 infrastructure/src/bin/bat/glossary_query.rs create mode 100644 infrastructure/src/glossary.rs create mode 100644 internal/api/testdata/contract/glossary-query.json diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 645786a..3e38fb3 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -38,6 +38,12 @@ job/unit/provider run 状态;`translation.memory.summary/query/confirm` 提供 Rust-owned TM 的摘要、source/context 查询和显式 trusted 确认,`bat-api` 仅作 typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取 任务,最终资源报告按 pull plan 顺序输出。 +项目级 Glossary V1 位于 `/glossary.sqlite`,独立于 release task 和 TM; +Rust `bat` 持有 term/alias/recommended/allowed/category/priority、全局或 +TextUnit scope、source history 和 approved review。worker、TM 复用、人工 task +结果和 workbench publish 都执行确定性 QA;blocking deviation 必须携带 +reviewer/reason/provenance 的显式 override。`translation.glossary.*` 已通过 +`bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。 --- @@ -88,7 +94,7 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成 待完成: - 领域服务模块仍为空。 -- Glossary、Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。 +- Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。 - 公共错误模型需要与 CLI/API 错误码统一。 ### `bat-adapters` @@ -149,7 +155,7 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成 待完成: -- 基于已接入的 `translation.worker.run` 继续推进 Glossary、完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM 查询和通用 Patch 发布资源视图。 +- 基于已接入的 `translation.worker.run` 继续推进完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM/Glossary 查询和通用 Patch 发布资源视图。 - 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。 - 增加更多权限和极端文件系统场景测试。 @@ -272,7 +278,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --watch ``` -资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest` 和 `official-sync-snapshot.json` contract fixture,Go mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移;TM 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text 和 provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。 +资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest`、`official-sync-snapshot.json` 和 Glossary query contract fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。 生产要求: @@ -299,7 +305,7 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远 1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。 2. 继续通用 Patch:manifest 驱动、双 release 查询和清理策略。 -3. 继续资源查询和翻译基础设施:更丰富的查询、Glossary 和 Provider +3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider 扩展体系。 4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。 @@ -310,6 +316,6 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远 HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard 和 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、 通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用; - 完整 AssetBundle 重打包、完整 Web 协作后台、Glossary、模糊 TM 匹配和通用 manifest 发布未完成。 -- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、Glossary、通用 manifest Patch + 完整 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和通用 manifest 发布未完成。 +- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、通用 manifest Patch 构建,以及真实官方资源长期运行验证。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index ec7accb..2da7b09 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -261,8 +261,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 交付物: 1. Translation Memory V1 已使用项目级 SQLite schema:source raw/hash、translation、完整 context、candidate/trusted 和 provenance。 -2. 已实现 raw source + 完整 context exact match;模糊匹配、Glossary 联动和完整导入导出仍待实现。 -3. 已实现显式 per-record confirm;术语优先级、别名、分类、冲突检测和审核队列仍待实现。 +2. 已实现 raw source + 完整 context exact match;模糊匹配和完整导入导出仍待实现。Glossary V1 已作为独立项目级 SQLite 资产接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。 +3. 已实现显式 per-record confirm;Glossary V1 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。 4. 已实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC。 验收标准: @@ -387,7 +387,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 ## 5. 推荐执行顺序 -近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是 Glossary、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。 +近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。 建议顺序: @@ -405,7 +405,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 `docs/reports/GO_STATUS.md`: 1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。 -2. 基于 `translation.worker.run` 继续推进 Glossary 和 Patch 构建。 +2. 基于 `translation.worker.run` 继续推进 Patch 构建。 3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。 4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。 @@ -461,9 +461,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。 -已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM 扩展、Glossary、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。 +已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。 --- - **下一份应补充的验证材料**:真实官方网络 smoke 运行记录 -- **下一项工程任务**:推进 TM 扩展、Glossary、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。 +- **下一项工程任务**:推进 TM/Glossary 扩展、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。 diff --git a/README.md b/README.md index 3ddc59f..950a0a4 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,14 @@ - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 -- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 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/`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`,report 按 plan 顺序、进度按完成数单调上报)、已发布历史 release 与 CAS 复用、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json`、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*`、`resource.*`、`parse.*`、`translation.tasks/handoff/task.update/worker.run`、`translation.memory.*`、`translation.glossary.*`、`localized.status`、`catalog.*`、`task.*`、`patch.apply`、`unityfs.patch_*`)。 - `internal/backendrpc`:Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。 - `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation/TM admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。 - Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。 - 官方同步会维护 `/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询索引,常用 metadata 过滤会下推到 SQLite;历史 release 复用会重新校验 size、BLAKE3 和 ZIP 结构,失败时按历史 release、CAS、网络顺序回退,CAS 引用记录在 `official-cas-reuse-references.json` 中;`bat doctor cas` 可只读诊断既有 CAS 目录、对象数、对象字节数和元数据库文件状态。 - 新 release 发布后会生成 `official-resource-changes.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json`、`crowdin-translation-handoff.json`、`crowdin-textunit-queue.json`、`translation-tasks.sqlite` 和 `translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。 -- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库和 Translation Memory V1;TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。Glossary、模糊匹配和完整 Provider 扩展体系仍待实现。 +- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库、Translation Memory V1 和 Glossary V1;Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。 - `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/` 并切换 `current`,也可显式 rollback。 - `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。 - `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 @@ -31,7 +31,7 @@ - `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。 - 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。 - 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。 -- Translation Memory、Glossary 和完整 Provider 扩展体系:其中 Translation Memory V1 已由 Rust `bat` 持有;仍未实现的是 Glossary、模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。 +- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V1 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。 - SDK、完整 Web 协作后台。 详细状态见: @@ -191,7 +191,7 @@ BlueArchiveToolkit/ 1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard;`cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,不应误写成完整产品 CLI。 2. 补齐 AssetBundle UnityFS 引擎级解析。 3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。 -4. 基于 `translation.worker.run` provider worker 扩展 Glossary、完整 Patch 构建和发布/回滚闭环。 +4. 基于 `translation.worker.run` provider worker 继续推进完整 Patch 构建和发布/回滚闭环。 5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。 当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在 TM 扩展、权限模型和持久化 API 明确后推进。 diff --git a/USERGUIDE.md b/USERGUIDE.md index 549cd00..0eeacb4 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -152,6 +152,9 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \ | `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token | | `GET /admin/translation/memory/summary` | 读取 Rust TM schema、记录总数及 candidate/trusted 等状态计数;需要管理 token | | `GET /admin/translation/memory/query?source_text=...&source_context=...&limit=100` | 按 raw source/context 查询 Rust TM 记录、复用判定和 provenance;需要管理 token | +| `GET /admin/translation/glossary/summary` | 读取 Rust Glossary schema 和 review-state 计数;需要管理 token | +| `GET /admin/translation/glossary/query?source_text=...&review_status=approved&limit=100` | 查询 Rust term、scope、source provenance 和 history;需要管理 token | +| `GET /admin/translation/glossary/diagnose?source_text=...&context=...` | 执行 deterministic Glossary constraints/diagnostics QA;需要管理 token | | `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token | | `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 | @@ -187,6 +190,10 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn | `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task | | `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 | | `translation-memory-confirm` | `translation.memory.confirm` | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | `202` + 已确认的 TM 记录 | +| `translation-glossary-add` | `translation.glossary.add` | term draft JSON | `202` + Glossary term | +| `translation-glossary-update` | `translation.glossary.update` | term draft + `reviewer` | `202` + Glossary term | +| `translation-glossary-approve` | `translation.glossary.approve` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + approved term | +| `translation-glossary-deprecate` | `translation.glossary.deprecate` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deprecated term | | `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }` 或 `{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest | | `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report | @@ -286,7 +293,7 @@ curl -i -H 'Range: bytes=0-1023' \ - `config.toml` 的字段按职责分组:`[runtime]`、`[resource]`、`[localized]`、`[repository]`、`[network]`、`[translation.worker]`。 - 现有 `BAT_*` 环境变量仍然有效,可继续覆盖 `config.toml` 中的同名配置。 - `BAT_SKIP_ENV_FILE` 已废弃且不再影响启动。 -- 支持的环境变量:`BAT_OUTPUT`、`BAT_LOCALIZED_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_IMPORT_REPOSITORY`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_DOWNLOAD_CONCURRENCY`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`、`BAT_TRANSLATION_PROVIDER`、`BAT_TRANSLATION_FIXTURE`、`BAT_TRANSLATION_MEMORY_PATH`、`BAT_TRANSLATION_CONCURRENCY`、`BAT_TRANSLATION_MAX_ATTEMPTS`、`BAT_TRANSLATION_LEASE_SECONDS`、`BAT_TRANSLATION_RETRY_BACKOFF_SECONDS`、`BAT_TRANSLATION_MAX_TASKS`、`BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。 +- 支持的环境变量:`BAT_OUTPUT`、`BAT_LOCALIZED_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_IMPORT_REPOSITORY`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_DOWNLOAD_CONCURRENCY`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`、`BAT_TRANSLATION_PROVIDER`、`BAT_TRANSLATION_FIXTURE`、`BAT_TRANSLATION_MEMORY_PATH`、`BAT_GLOSSARY_PATH`、`BAT_TRANSLATION_CONCURRENCY`、`BAT_TRANSLATION_MAX_ATTEMPTS`、`BAT_TRANSLATION_LEASE_SECONDS`、`BAT_TRANSLATION_RETRY_BACKOFF_SECONDS`、`BAT_TRANSLATION_MAX_TASKS`、`BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。 - `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时运行模式设置让位。`status` / `verify` 等子命令不受它们影响。 - 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。 - `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `/bat-tasks.json`。 @@ -311,6 +318,34 @@ RPC `translation.memory.summary`、`translation.memory.query`、`translation.mem context 不完整或不一致、normalized source 辅助命中和 workflow `proofread` 都不会自动 建立 trusted 记录。 +### Glossary V1 + +Glossary 由 Rust `bat` 独立持有,默认路径为 `/glossary.sqlite`,不位于 +`versions/`,也不与 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. 退出码 diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index b65999f..c1c3b7f 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -454,6 +454,86 @@ paths: description: Missing or invalid admin token. "503": description: Rust bat Translation Memory backend is unavailable. + /admin/translation/glossary/summary: + get: + summary: Read Rust-owned Glossary summary + parameters: + - name: glossary_path + in: query + schema: + type: string + responses: + "200": + description: Glossary availability and review-state counts. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Glossary backend is unavailable. + /admin/translation/glossary/query: + get: + summary: Query Rust-owned Glossary terms + parameters: + - name: source_text + in: query + schema: + type: string + - name: category + in: query + schema: + type: string + - name: review_status + in: query + schema: + type: string + enum: [draft, approved, deprecated, rejected] + - name: limit + in: query + schema: + type: integer + format: int64 + minimum: 1 + maximum: 1000 + default: 100 + - name: glossary_path + in: query + schema: + type: string + responses: + "200": + description: Glossary terms with source and review history. + "400": + description: Invalid Glossary query. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Glossary backend is unavailable. + /admin/translation/glossary/diagnose: + get: + summary: Run deterministic Glossary diagnostics + parameters: + - name: source_text + in: query + required: true + schema: + type: string + - name: context + in: query + description: JSON object whose values are strings. + schema: + type: string + - name: glossary_path + in: query + schema: + type: string + responses: + "200": + description: Glossary constraints, diagnostics, and blocked decision. + "400": + description: Missing source text or invalid context. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Glossary backend is unavailable. /admin/translation/status: get: summary: Read Rust-owned localized release status @@ -473,7 +553,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, localized-publish, localized-rollback] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, localized-publish, localized-rollback] requestBody: required: false content: @@ -566,8 +646,57 @@ paths: type: string translation_memory_path: type: string + glossary_path: + type: string record_id: type: string + term_id: + type: string + source_term: + type: string + aliases: + type: array + items: + type: string + recommended_translation: + type: string + allowed_translations: + type: array + items: + type: string + source_language: + type: string + target_language: + type: string + category: + type: string + priority: + type: integer + format: int64 + scope: + type: object + additionalProperties: + type: string + review_status: + type: string + enum: [draft, approved, deprecated, rejected] + source: + type: object + additionalProperties: false + required: [source_kind, observed_unix_seconds] + properties: + source_kind: + type: string + enum: [manual, imported] + source_ref: + type: string + source_author: + type: string + source_note: + type: string + observed_unix_seconds: + type: integer + format: int64 reviewer: type: string reason: diff --git a/core/src/domain/glossary.rs b/core/src/domain/glossary.rs new file mode 100644 index 0000000..7ccf097 --- /dev/null +++ b/core/src/domain/glossary.rs @@ -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 { + 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 { + 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, + /// Person or system that supplied the source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_author: Option, + /// Source note. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_note: Option, + /// 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, + /// Human reason for the mutation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// 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, + /// 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, + /// Optional source language. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_language: Option, + /// Optional target language. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_language: Option, + /// Optional category. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// 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, +} + +/// 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, + /// 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, + /// Optional category. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// Term priority. + pub priority: i32, + /// Matching scope. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub scope: BTreeMap, +} + +/// 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, + /// Source spelling or target text involved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + /// 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, + /// Matching diagnostics, including explicit overrides and conflicts. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, + /// 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, + /// Deterministic diagnostics. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +/// 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, +) -> GlossaryEvaluation { + #[derive(Clone)] + struct Candidate { + term_id: String, + matched_source: String, + recommendation: String, + allowed: Vec, + category: Option, + priority: i32, + scope: BTreeMap, + 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, + ) -> 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)); + } +} diff --git a/core/src/domain/mod.rs b/core/src/domain/mod.rs index 5ad4642..c3c8396 100644 --- a/core/src/domain/mod.rs +++ b/core/src/domain/mod.rs @@ -2,12 +2,19 @@ pub mod game_client; pub mod game_version; +pub mod glossary; pub mod resource; pub mod translation; pub mod translation_memory; pub use game_client::{ClientStatus, GameClient, GameRegion}; pub use game_version::{GameVersion, UnityVersion}; +pub use glossary::{ + evaluate_glossary, validate_glossary_draft, GlossaryConstraint, GlossaryDiagnostic, + GlossaryDiagnosticKind, GlossaryEvaluation, GlossaryHistoryRecord, GlossaryOverride, + GlossaryQaReport, GlossaryQaStatus, GlossaryReviewStatus, GlossarySourceKind, + GlossarySourceRecord, GlossarySummary, GlossaryTerm, GlossaryTermDraft, GlossaryTermSnapshot, +}; pub use resource::{ crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType, }; diff --git a/core/src/repositories/glossary_repository.rs b/core/src/repositories/glossary_repository.rs new file mode 100644 index 0000000..2f2b1fe --- /dev/null +++ b/core/src/repositories/glossary_repository.rs @@ -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; +} diff --git a/core/src/repositories/mod.rs b/core/src/repositories/mod.rs index a0802de..dc6804f 100644 --- a/core/src/repositories/mod.rs +++ b/core/src/repositories/mod.rs @@ -3,11 +3,13 @@ //! 定义所有数据访问接口 pub mod cas_repository; +pub mod glossary_repository; pub mod resource_repository; pub mod translation_memory_repository; pub mod translation_repository; pub use cas_repository::CasRepository; +pub use glossary_repository::GlossaryRepository; pub use resource_repository::ResourceRepository; pub use translation_memory_repository::TranslationMemoryRepository; pub use translation_repository::TranslationRepository; diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1545237..2be0b48 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -196,13 +196,14 @@ pub struct ParserRegistry { ### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担) 当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、 -lease/retry、结果落库和项目级 Translation Memory V1。TM 位于独立 SQLite,按 raw -source + 完整 context 做 trusted exact reuse,candidate 必须显式 confirm;Glossary、 -模糊匹配和完整 Provider 体系仍属后续缺口。 +lease/retry、结果落库、项目级 Translation Memory V1 和独立 Glossary V1。TM 位于独立 +SQLite,按 raw source + 完整 context 做 trusted exact reuse,candidate 必须显式 confirm; +Glossary 只有 approved term 进入 provider/TM 自动流程,并在结果上执行确定性 QA;模糊 +匹配和完整 Provider 体系仍属后续缺口。 **架构**: ``` -Text Extractor → TM exact query → AI Provider → Glossary (后续) → Output +Text Extractor → Glossary constraints + TM exact query → AI Provider → Glossary QA → Output ↓ ↓ PostgreSQL 审核队列 ``` @@ -295,7 +296,7 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep ### 7. Web 后台 (Vue 3,目标设计) -当前只有 `bat-api` 内嵌 dashboard MVP;登录、角色、术语管理和完整协作审核仍未实现。 +当前只有 `bat-api` 内嵌 dashboard MVP;Rust `bat` 的 Glossary V1 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。 **技术栈**: - Vue 3 + Composition API @@ -308,7 +309,7 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep **模块**: - Dashboard(统计概览) - 翻译审核(Translation Review) -- 术语管理(Glossary Manager) +- Web 术语管理(Glossary Manager) - 资源浏览(Asset Browser) - 用户管理(User Management) diff --git a/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md b/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md index bad0623..2d3a80e 100644 --- a/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md +++ b/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md @@ -36,7 +36,7 @@ - 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由 `internal/backendrpc` 消费。 -4. **完整游戏业务 API、完整 Web 协作后台、Glossary 和 Provider 扩展体系仍是后续目标**; +4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有 Glossary V1,Web 术语协作视图仍待建设**; Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。 --- diff --git a/docs/guides/baseline.md b/docs/guides/baseline.md index 3811079..0ecfd0f 100644 --- a/docs/guides/baseline.md +++ b/docs/guides/baseline.md @@ -89,7 +89,7 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md 当前开发优先推进: 1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。 -2. 基于 `translation.worker.run` 扩展 TM/Glossary 和通用 manifest Patch 构建。 +2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并推进通用 manifest Patch 构建。 3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。 4. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。 5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。 diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index 88a7626..406ba08 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -161,6 +161,22 @@ bat i18n worker run \ `--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须 显式指定 `--interval`。 +Glossary V1 是 Rust `bat` 持有的独立项目级 SQLite 资产,默认位于 +`/glossary.sqlite`;也可以用 `--glossary-path`、 +`BAT_GLOSSARY_PATH` 或 `[translation.worker].glossary_path` 指定。worker 只把 +`approved` term 转成 provider-neutral constraints,并在 TM 复用、provider 返回 +和人工工作台/任务回写时执行相同的确定性 QA。冲突或不符合推荐/允许译法的结果会 +阻止自动完成;必须提交带 reviewer、reason 和 provenance 的显式 override。 + +常用 Glossary 操作: + +```bash +bat i18n glossary summary --output /tmp/bat-resources +bat i18n glossary query --glossary-source-text 'Sensei' --glossary-review-status approved +bat i18n glossary diagnose --glossary-source-text 'Sensei' \ + --glossary-context-json '{"destination":"Table.bytes"}' +``` + 外部 provider 或人工流程也可以用 `i18n task update` 回写当前 release 的任务状态: ```bash diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index f9e294b..81f70c4 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -8,7 +8,7 @@ BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式: 本地资源状态使用文件和 SQLite。 2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。 3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root`。 -4. **可选数据库开发环境**:PostgreSQL/Redis 只服务于未来的 Go 服务层、Glossary 和完整 +4. **可选数据库开发环境**:PostgreSQL/Redis 只服务于未来的 Go 服务层、完整 Web 协作后台和 Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory V1 使用 `/translation-memory.sqlite`。 5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。 @@ -39,7 +39,7 @@ docker compose -f deployments/docker-compose.dev.yml --profile local-db up -d ## 模式 2:可选数据库开发环境(目标能力) PostgreSQL 和 Redis 不是当前 `bat` / `bat-api` 的生产运行依赖。本模式只用于未来 -服务层、Glossary 或 Provider 扩展的开发验证,不能作为当前 +服务层、Web 协作视图或 Provider 扩展的开发验证,不能作为当前 资源同步或资源分发的部署前置条件。 ### 远程开发连接 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 42a4475..e8227af 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -164,6 +164,11 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 provenance。默认路径为 `/translation-memory.sqlite`,可由 `BAT_TRANSLATION_MEMORY_PATH`、`[translation.worker].translation_memory_path` 或 CLI 覆盖。 +- `glossary.sqlite`:跨 release 的项目级 Glossary,不位于 `versions/`,也不与 + `translation-tasks.sqlite` 或 TM 共用;记录 term、alias、推荐/允许译法、scope、 + priority、review 状态、source provenance 和完整 source/review history。默认路径为 + `/glossary.sqlite`,可由 `BAT_GLOSSARY_PATH`、`[translation.worker].glossary_path` + 或 CLI 覆盖。 删除资源只进入 `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.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenance。 | | `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | 显式确认一条 candidate 为 trusted;worker 之后才可自动复用。 | +| `translation.glossary.summary` | 已实现 | 可选 `{ "glossary_path": "..." }` | 返回 Glossary schema 版本和 draft/approved/deprecated/rejected 计数;缺库只返回 `available=false`,不会创建空库。 | +| `translation.glossary.query` | 已实现 | `{ "source_text": "...", "category": "...", "review_status": "approved", "limit": 100 }` | 查询 term、alias、scope、source provenance 和完整 source/review history。 | +| `translation.glossary.diagnose` | 已实现 | `{ "source_text": "...", "context": {...} }` | 只对 approved term 生成 provider-neutral constraints,并返回冲突/覆盖诊断和 blocked 决策。 | +| `translation.glossary.add` | 已实现 | Glossary term draft,包含 `term_id`、`source_term`、`recommended_translation`、`source` 等 | Rust 创建 draft/import term 并记录 source history。 | +| `translation.glossary.update` | 已实现 | term draft + `reviewer`,可选 `reason` | Rust 替换 term definition,并记录 source/review history。 | +| `translation.glossary.approve` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 将 term 明确置为 approved;只有 approved term 进入 worker/TM 自动流程。 | +| `translation.glossary.deprecate` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 保留历史但停止自动应用。 | TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted`; context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会 @@ -299,6 +311,7 @@ provider worker 参数: | `max_tasks` | uint/null | `null` | 本轮最多 claim 的任务数,设置时必须大于 0。 | | `worker_id` | string | `bat-rpc-worker` | lease 诊断用 worker ID 前缀。 | | `translation_memory_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 TM 数据库路径;未指定时使用 worker 配置或 `/translation-memory.sqlite`。 | +| `glossary_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 Glossary 数据库路径;未指定时使用 worker 配置或 `/glossary.sqlite`。存在 Glossary 但无法打开时 worker fail-closed,不自动绕过 QA。 | 数字字段必须是 JSON number;字符串数字、负数和越界值会返回 `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`。 token 不会进入报告、任务记录或调试输出。 +Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 trusted TM +复用前、provider 返回后、人工 `translation.task.update` 和 workbench publish 前执行 +同一套确定性 QA;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法 +产生 warning;blocking deviation 必须在对应结果中提交 `glossary_override`,并包含 +`reviewer`、`reason`、`provenance` 和确认时间。系统不会在译文生成后做静默字符串替换。 + ### localized | 方法 | 状态 | params | data | @@ -449,6 +468,10 @@ CLI 对应关系: | `bat i18n proofread` | `translation.proofread` | | `bat i18n memory summary` / `bat i18n memory query` | `translation.memory.summary` / `translation.memory.query` | | `bat i18n memory confirm` | `translation.memory.confirm` | +| `bat i18n glossary summary` / `bat i18n glossary query` | `translation.glossary.summary` / `translation.glossary.query` | +| `bat i18n glossary diagnose` | `translation.glossary.diagnose` | +| `bat i18n glossary add/update` | `translation.glossary.add` / `translation.glossary.update` | +| `bat i18n glossary approve/deprecate` | `translation.glossary.approve` / `translation.glossary.deprecate` | | `bat localized-status` | `localized.status` | | `bat resource-index` | `resource.index` | @@ -481,7 +504,9 @@ CLI 对应关系: `localized.status`、`localized.publish`、`localized.rollback`、 `translation.tasks`、`translation.handoff`、`translation.task.update`、 `translation.worker.run`、`translation.proofread`、`translation.memory.summary`、 - `translation.memory.query`、`translation.memory.confirm`、 + `translation.memory.query`、`translation.memory.confirm`、`translation.glossary.summary`、 + `translation.glossary.query`、`translation.glossary.diagnose`、`translation.glossary.add`、 + `translation.glossary.update`、`translation.glossary.approve`、`translation.glossary.deprecate`、 `task.*` 和三个 `unityfs.patch_*` 方法。 - `resource.index` 和 `patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守 本契约的参数和响应定义。 @@ -498,6 +523,7 @@ CLI 对应关系: | `ParseBackend` | `parse.status`、`parse.text_units`、`parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 | | `TranslationBackend` | `translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 | | `TranslationMemoryBackend` | `translation.memory.summary`、`translation.memory.query`、`translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认;Go 只转发,不持有 TM 状态 | +| `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核 mutation;Go 只转发,不持有 Glossary 状态 | | `LocalizedBackend` | `localized.status`、`localized.publish`、`localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 | `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/`,覆盖 `catalog.status` available/unavailable、`resource.manifest` page0、对应 -`official-sync-snapshot.json` 以及 Translation Memory query/缺库 mirror。 +`official-sync-snapshot.json`、Translation Memory query/缺库 mirror 和 Glossary +query/source-history mirror。 这些 fixture/mirror 由 Rust 输出形状归一化而来,只用于 schema / mirror 回归;live daemon socket 和完整 fixture release 切换由 `make bat-api-local-live-smoke` 在同机 `/tmp` 隔离环境中验证。该 smoke 不替代 diff --git a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md index 9595a56..a83e979 100644 --- a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md +++ b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md @@ -50,11 +50,13 @@ catalog-status.unavailable.raw.json resource-manifest.page0.raw.json official-sync-snapshot.raw.json + glossary-query.raw.json normalized/ catalog-status.available.json catalog-status.unavailable.json resource-manifest.page0.json official-sync-snapshot.json + glossary-query.json notes.md ``` @@ -72,6 +74,8 @@ Rust 窗口请基于当前真实代码生成或导出以下 JSON: 2. `catalog.status` available=false 响应。 3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。 4. 对应 release 的 `official-sync-snapshot.json`。 +5. Rust Glossary V1 的 `translation.glossary.query` 响应,至少包含 alias、approved + review、source provenance 和 created/approved history。 输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index ebcf226..079a372 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -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 联动和更丰富的导入导出历史能力。 -### G-013:Glossary 未实现 +### G-013:Glossary V1 已实现,协作视图仍缺失 -需要支持术语优先级、别名、分类、冲突检测和审核。 +Rust `bat` 已提供独立项目级 `glossary.sqlite`:term/alias/recommended/allowed/category/priority、全局与 TextUnit scope、source history、approved review、冲突诊断、provider-neutral constraints 和确定性 QA 均由 Rust 持有。trusted TM 复用会先经过 Glossary QA;provider、TM、人工 task/workbench 结果都记录 QA,blocking deviation 必须显式提交 reviewer/reason/provenance。`translation.glossary.*` 已通过 `bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图和更丰富的导入/搜索能力。 ### G-014:完整 Provider 扩展体系未实现 @@ -145,7 +145,7 @@ Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整 1. 继续 G-005:真实 AssetBundle 样本、复杂字段解析和发布级重打包。 2. 继续 G-006/G-011D:通用 manifest Patch 和双 release 查询/清理策略。 -3. 继续 G-011/G-012/G-013/G-014:资源查询、TM 扩展、Glossary 和 Provider +3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider 扩展体系。 4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。 5. 最后推进完整 Web 协作后台和完整游戏业务 API。 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index babc9c9..b126327 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -95,8 +95,8 @@ | 组件 | 路径 | 状态 | 说明 | |---|---|---|---| | Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | -| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | -| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM admin forwarding + 内嵌 dashboard + `.env` | +| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | +| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | | 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README | diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index f3d54f3..4548536 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -13,8 +13,9 @@ use bat_infrastructure::{ localized_patch_operations, open_append_file, read_download_manifest_at, read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state, - redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation, - validate_output_root, validate_runtime_state_dir, validate_translation_workbench, + redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, + set_translation_checked_with_glossary_path, unset_translation, validate_output_root, + validate_runtime_state_dir, validate_translation_workbench_with_glossary_path, write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport, OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService, @@ -54,6 +55,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; #[path = "config_file.rs"] mod config_file; +#[path = "glossary_query.rs"] +mod glossary_query; #[path = "patch_commands.rs"] mod patch_commands; #[path = "readonly_query.rs"] @@ -70,6 +73,11 @@ mod terminal_output; mod translation_query; #[path = "workflow_commands.rs"] mod workflow_commands; +use glossary_query::{ + glossary_diagnose_rpc_report, glossary_mutation_rpc_report, glossary_query_rpc_report, + glossary_review_rpc_report, glossary_rpc_envelope, glossary_summary_rpc_report, + run_glossary_command, +}; use patch_commands::{ is_write_patch_command, run_write_patch_command, validate_write_patch_options, }; @@ -229,6 +237,16 @@ fn run() -> anyhow::Result { run_translation_memory_command(&options)?; Ok(0) } + CliCommand::GlossarySummary + | CliCommand::GlossaryQuery + | CliCommand::GlossaryAdd + | CliCommand::GlossaryUpdate + | CliCommand::GlossaryApprove + | CliCommand::GlossaryDeprecate + | CliCommand::GlossaryDiagnose => { + run_glossary_command(&options)?; + Ok(0) + } CliCommand::TranslationWorker => { run_repeated_workflow(&options, "translation-worker", run_translation_worker)?; Ok(0) @@ -385,6 +403,8 @@ struct CliOptions { translation_provider: Option, translation_fixture: Option, translation_memory_path: Option, + glossary_path: Option, + glossary_path_option_explicit: bool, translation_memory_option_explicit: bool, translation_memory_command_option_explicit: bool, translation_memory_source_text: Option, @@ -392,6 +412,26 @@ struct CliOptions { translation_memory_record_id: Option, translation_memory_reviewer: Option, translation_memory_reason: Option, + glossary_term_id: Option, + glossary_source_term: Option, + glossary_aliases_json: Option, + glossary_recommended_translation: Option, + glossary_allowed_translations_json: Option, + glossary_source_language: Option, + glossary_target_language: Option, + glossary_category: Option, + glossary_priority: i32, + glossary_scope_json: Option, + glossary_source_kind: Option, + glossary_source_ref: Option, + glossary_source_author: Option, + glossary_source_note: Option, + glossary_reviewer: Option, + glossary_reason: Option, + glossary_override_provenance: Option, + glossary_source_text: Option, + glossary_context_json: Option, + glossary_review_status: Option, worker_concurrency: usize, worker_max_attempts: u32, worker_lease_seconds: u64, @@ -488,6 +528,8 @@ impl Default for CliOptions { translation_provider: None, translation_fixture: None, translation_memory_path: None, + glossary_path: None, + glossary_path_option_explicit: false, translation_memory_option_explicit: false, translation_memory_command_option_explicit: false, translation_memory_source_text: None, @@ -495,6 +537,26 @@ impl Default for CliOptions { translation_memory_record_id: None, translation_memory_reviewer: None, translation_memory_reason: None, + glossary_term_id: None, + glossary_source_term: None, + glossary_aliases_json: None, + glossary_recommended_translation: None, + glossary_allowed_translations_json: None, + glossary_source_language: None, + glossary_target_language: None, + glossary_category: None, + glossary_priority: 0, + glossary_scope_json: None, + glossary_source_kind: None, + glossary_source_ref: None, + glossary_source_author: None, + glossary_source_note: None, + glossary_reviewer: None, + glossary_reason: None, + glossary_override_provenance: None, + glossary_source_text: None, + glossary_context_json: None, + glossary_review_status: None, worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY, worker_max_attempts: DEFAULT_TRANSLATION_MAX_ATTEMPTS, worker_lease_seconds: DEFAULT_TRANSLATION_LEASE_SECONDS, @@ -590,6 +652,13 @@ enum CliCommand { TranslationMemorySummary, TranslationMemoryQuery, TranslationMemoryConfirm, + GlossarySummary, + GlossaryQuery, + GlossaryAdd, + GlossaryUpdate, + GlossaryApprove, + GlossaryDeprecate, + GlossaryDiagnose, Repack, PublishLocalized, 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_QUERY: &str = "translation.memory.query"; const RPC_METHOD_TRANSLATION_MEMORY_CONFIRM: &str = "translation.memory.confirm"; +const RPC_METHOD_GLOSSARY_SUMMARY: &str = "translation.glossary.summary"; +const RPC_METHOD_GLOSSARY_QUERY: &str = "translation.glossary.query"; +const RPC_METHOD_GLOSSARY_ADD: &str = "translation.glossary.add"; +const RPC_METHOD_GLOSSARY_UPDATE: &str = "translation.glossary.update"; +const RPC_METHOD_GLOSSARY_APPROVE: &str = "translation.glossary.approve"; +const RPC_METHOD_GLOSSARY_DEPRECATE: &str = "translation.glossary.deprecate"; +const RPC_METHOD_GLOSSARY_DIAGNOSE: &str = "translation.glossary.diagnose"; const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status"; const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish"; const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback"; @@ -2105,7 +2181,11 @@ fn dispatch_rpc_method( RPC_METHOD_TRANSLATION_TASK_UPDATE => rpc_envelope_from_result( request_id, "translation.task.update", - update_translation_task_status_report(state_dir, request.params.as_ref()), + update_translation_task_status_report( + state_dir, + request.params.as_ref(), + tasks.translation_worker_config.glossary_path.as_deref(), + ), ), RPC_METHOD_TRANSLATION_PROOFREAD => { let _sync_guard = tasks @@ -2155,6 +2235,67 @@ fn dispatch_rpc_method( request.params.as_ref(), ), ), + RPC_METHOD_GLOSSARY_SUMMARY => glossary_rpc_envelope( + request_id, + glossary_summary_rpc_report( + state_dir, + &tasks.base_config.output_root, + tasks.translation_worker_config.glossary_path.as_deref(), + request.params.as_ref(), + ), + ), + RPC_METHOD_GLOSSARY_QUERY => glossary_rpc_envelope( + request_id, + glossary_query_rpc_report( + state_dir, + &tasks.base_config.output_root, + tasks.translation_worker_config.glossary_path.as_deref(), + request.params.as_ref(), + ), + ), + RPC_METHOD_GLOSSARY_DIAGNOSE => glossary_rpc_envelope( + request_id, + glossary_diagnose_rpc_report( + state_dir, + &tasks.base_config.output_root, + tasks.translation_worker_config.glossary_path.as_deref(), + request.params.as_ref(), + ), + ), + RPC_METHOD_GLOSSARY_ADD => glossary_rpc_envelope( + request_id, + glossary_mutation_rpc_report( + state_dir, + &tasks.base_config.output_root, + tasks.translation_worker_config.glossary_path.as_deref(), + request.params.as_ref(), + false, + ), + ), + RPC_METHOD_GLOSSARY_UPDATE => glossary_rpc_envelope( + request_id, + glossary_mutation_rpc_report( + state_dir, + &tasks.base_config.output_root, + tasks.translation_worker_config.glossary_path.as_deref(), + request.params.as_ref(), + true, + ), + ), + RPC_METHOD_GLOSSARY_APPROVE | RPC_METHOD_GLOSSARY_DEPRECATE => glossary_rpc_envelope( + request_id, + glossary_review_rpc_report( + state_dir, + &tasks.base_config.output_root, + tasks.translation_worker_config.glossary_path.as_deref(), + request.params.as_ref(), + if request.method == RPC_METHOD_GLOSSARY_APPROVE { + bat_core::domain::GlossaryReviewStatus::Approved + } else { + bat_core::domain::GlossaryReviewStatus::Deprecated + }, + ), + ), RPC_METHOD_TRANSLATION_WORKER_RUN => { let config = match rpc_translation_worker_config_with_defaults( request.params.as_ref(), @@ -3504,6 +3645,13 @@ fn rpc_translation_worker_config_with_defaults( )? .map(PathBuf::from) .or_else(|| defaults.translation_memory_path.clone()), + glossary_path: rpc_translation_worker_string_param( + params, + &["glossary_path", "translation_glossary_path"], + "glossary_path", + )? + .map(PathBuf::from) + .or_else(|| defaults.glossary_path.clone()), }; config.validate().map_err(|error| { ApiError::new( @@ -6109,6 +6257,10 @@ fn daemon_child_args(options: &CliOptions) -> Vec { args.push("--translation-memory-path".to_string()); args.push(path.to_string_lossy().to_string()); } + if let Some(path) = options.glossary_path.as_ref() { + args.push("--glossary-path".to_string()); + args.push(path.to_string_lossy().to_string()); + } args.push("--worker-concurrency".to_string()); args.push(options.worker_concurrency.to_string()); args.push("--worker-max-attempts".to_string()); @@ -6420,6 +6572,9 @@ fn apply_bat_env_overrides( { options.translation_memory_path = Some(PathBuf::from(v)); } + if let Some(v) = value("BAT_GLOSSARY_PATH") { + options.glossary_path = Some(PathBuf::from(v)); + } if let Some(v) = value("BAT_TRANSLATION_CONCURRENCY") { options.worker_concurrency = parse_translation_worker_concurrency(&v, "环境变量 BAT_TRANSLATION_CONCURRENCY")?; @@ -6702,6 +6857,10 @@ fn parse_args_with_env( options.translation_memory_option_explicit = true; options.translation_worker_option_explicit = true; } + "--glossary-path" | "--translation-glossary-path" => { + options.glossary_path = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + options.glossary_path_option_explicit = true; + } "--tm-source-text" => { options.translation_memory_source_text = Some(next_option_value(&mut args, &flag)?); options.translation_memory_command_option_explicit = true; @@ -6723,6 +6882,70 @@ fn parse_args_with_env( options.translation_memory_reason = Some(next_option_value(&mut args, &flag)?); options.translation_memory_command_option_explicit = true; } + "--glossary-term-id" => { + options.glossary_term_id = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-source-term" => { + options.glossary_source_term = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-aliases-json" => { + options.glossary_aliases_json = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-recommended-translation" => { + options.glossary_recommended_translation = + Some(next_option_value(&mut args, &flag)?); + } + "--glossary-allowed-translations-json" => { + options.glossary_allowed_translations_json = + Some(next_option_value(&mut args, &flag)?); + } + "--glossary-source-language" => { + options.glossary_source_language = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-target-language" => { + options.glossary_target_language = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-category" => { + options.glossary_category = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-priority" => { + options.glossary_priority = next_option_value(&mut args, &flag)? + .parse() + .map_err(|error| anyhow::anyhow!("--glossary-priority 无效:{error}"))?; + } + "--glossary-scope-json" => { + options.glossary_scope_json = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-source-kind" => { + options.glossary_source_kind = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-source-ref" => { + options.glossary_source_ref = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-source-author" => { + options.glossary_source_author = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-source-note" => { + options.glossary_source_note = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-reviewer" => { + options.glossary_reviewer = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-reason" => { + options.glossary_reason = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-provenance" | "--glossary-override-provenance" => { + options.glossary_override_provenance = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-source-text" => { + options.glossary_source_text = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-context-json" => { + options.glossary_context_json = Some(next_option_value(&mut args, &flag)?); + } + "--glossary-review-status" => { + options.glossary_review_status = Some(next_option_value(&mut args, &flag)?); + } "--worker-concurrency" | "--translation-concurrency" => { options.worker_concurrency = parse_translation_worker_concurrency( &next_option_value(&mut args, &flag)?, @@ -7240,6 +7463,30 @@ fn parse_args_with_env( "翻译 worker 参数只适用于 i18n worker run 或 daemon restart/reload" )); } + if options.glossary_path_option_explicit + && !options.daemon_child + && !matches!( + options.command, + CliCommand::TranslationWorker + | CliCommand::GlossarySummary + | CliCommand::GlossaryQuery + | CliCommand::GlossaryAdd + | CliCommand::GlossaryUpdate + | CliCommand::GlossaryApprove + | CliCommand::GlossaryDeprecate + | CliCommand::GlossaryDiagnose + | CliCommand::TranslationSet + | CliCommand::TranslationValidate + | CliCommand::TranslationTaskUpdate + | CliCommand::PublishLocalized + | CliCommand::Restart + | CliCommand::Reload + ) + { + return Err(anyhow::anyhow!( + "Glossary 路径参数只适用于 Glossary、翻译 worker、工作台/任务发布或 daemon restart/reload" + )); + } if options.translation_memory_option_explicit && !matches!( options.command, @@ -7247,6 +7494,13 @@ fn parse_args_with_env( | CliCommand::TranslationMemorySummary | CliCommand::TranslationMemoryQuery | CliCommand::TranslationMemoryConfirm + | CliCommand::GlossarySummary + | CliCommand::GlossaryQuery + | CliCommand::GlossaryAdd + | CliCommand::GlossaryUpdate + | CliCommand::GlossaryApprove + | CliCommand::GlossaryDeprecate + | CliCommand::GlossaryDiagnose | CliCommand::Restart | CliCommand::Reload ) @@ -7369,6 +7623,74 @@ fn parse_args_with_env( options.progress = 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::UnityFsPatchTextAsset | CliCommand::UnityFsPatchStringField @@ -7916,6 +8238,9 @@ fn parse_translation_command( if action == "memory" || action == "tm" { return parse_translation_memory_command(args, options); } + if action == "glossary" || action == "terms" { + return parse_translation_glossary_command(args, options); + } let command = match action.as_str() { "run" => CliCommand::Translate, "export" => CliCommand::Translate, @@ -7956,6 +8281,30 @@ fn parse_translation_memory_command( Ok(()) } +fn parse_translation_glossary_command( + args: &mut impl Iterator, + 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( args: &mut impl Iterator, options: &mut CliOptions, @@ -8086,6 +8435,7 @@ fn translation_worker_config_from_options( .clone() .unwrap_or_else(|| default_worker_id.to_string()), translation_memory_path: options.translation_memory_path.clone(), + glossary_path: options.glossary_path.clone(), }; config.validate()?; Ok(config) diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 8af6f9c..3db07b9 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -366,6 +366,77 @@ fn translation_memory_subcommands_reject_irrelevant_options() { assert!(error.to_string().contains("query 参数")); } +#[test] +fn glossary_commands_parse_and_validate() { + let query = parse(&[ + "bat", + "i18n", + "glossary", + "query", + "--glossary-source-text", + "Sensei", + "--glossary-review-status", + "approved", + "--limit", + "5", + ]) + .unwrap(); + assert_eq!(query.command, CliCommand::GlossaryQuery); + assert_eq!(query.glossary_source_text.as_deref(), Some("Sensei")); + assert_eq!(query.glossary_review_status.as_deref(), Some("approved")); + assert_eq!(query.query_limit, 5); + + let add = parse(&[ + "bat", + "i18n", + "glossary", + "add", + "--glossary-term-id", + "term-sensei", + "--glossary-source-term", + "Sensei", + "--glossary-recommended-translation", + "老师", + "--glossary-source-kind", + "manual", + "--glossary-scope-json", + r#"{"destination":"story.bundle"}"#, + "--glossary-path", + "/tmp/project-glossary.sqlite", + ]) + .unwrap(); + assert_eq!(add.command, CliCommand::GlossaryAdd); + assert_eq!(add.glossary_term_id.as_deref(), Some("term-sensei")); + assert_eq!(add.glossary_priority, 0); + assert_eq!( + add.glossary_path, + Some(PathBuf::from("/tmp/project-glossary.sqlite")) + ); + + let diagnose = parse(&[ + "bat", + "translation", + "glossary", + "diagnose", + "--glossary-source-text", + "Sensei", + "--glossary-context-json", + r#"{"destination":"story.bundle"}"#, + ]) + .unwrap(); + assert_eq!(diagnose.command, CliCommand::GlossaryDiagnose); + assert!(parse(&["bat", "i18n", "glossary", "diagnose"]).is_err()); + assert!(parse(&[ + "bat", + "i18n", + "glossary", + "query", + "--glossary-term-id", + "term-sensei", + ]) + .is_err()); +} + #[test] fn translation_worker_env_defaults_apply() { let options = parse_with_env( @@ -765,6 +836,8 @@ fn translation_workbench_commands_read_update_and_clear_entries() { review_status: None, format: Some("plain".to_string()), text_source_kind: Some("text_asset".to_string()), + glossary_qa: None, + glossary_override: None, }], }; bat_infrastructure::write_translation_workbench(&path, &workbench).unwrap(); @@ -2679,6 +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::(); + let context = DaemonTaskContext { + registry: TaskRegistry::new(), + queue, + base_config: OfficialUpdateConfig { + output_root: output_root.clone(), + ..Default::default() + }, + translation_worker_config: TranslationWorkerConfig::default(), + sync_lock: Arc::new(Mutex::new(())), + restart_controller: test_restart_controller, + }; + + let envelope = dispatch_rpc_method( + &rpc_request("translation.glossary.summary", None), + &state_dir, + &new_daemon_control(), + &context, + "req-glossary-summary-1".to_string(), + ); + let value = serde_json::to_value(envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], false); + assert_eq!(value["data"]["reason"], "database_missing"); + assert!(!output_root.join("glossary.sqlite").exists()); +} + #[test] fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/infrastructure/src/bin/bat/config_file.rs b/infrastructure/src/bin/bat/config_file.rs index 8d0f57c..b990826 100644 --- a/infrastructure/src/bin/bat/config_file.rs +++ b/infrastructure/src/bin/bat/config_file.rs @@ -67,6 +67,7 @@ download_concurrency = 8 provider = 'mock' fixture = '' translation_memory_path = '' +glossary_path = '' concurrency = 8 max_attempts = 3 lease_seconds = 300 @@ -150,6 +151,7 @@ struct TranslationWorkerSection { provider: Option, fixture: Option, translation_memory_path: Option, + glossary_path: Option, concurrency: Option, max_attempts: Option, lease_seconds: Option, @@ -367,6 +369,9 @@ impl BatConfigFile { if let Some(value) = self.translation.worker.translation_memory_path.as_ref() { options.translation_memory_path = Some(value.clone()); } + if let Some(value) = self.translation.worker.glossary_path.as_ref() { + options.glossary_path = Some(value.clone()); + } if let Some(value) = self.translation.worker.concurrency { options.worker_concurrency = value; } @@ -591,6 +596,10 @@ impl BatConfigFile { line_number, )?; } + (SectionPath::TranslationWorker, "glossary_path") => { + self.translation.worker.glossary_path = + parse_optional_path(value, "translation.worker.glossary_path", line_number)?; + } (SectionPath::TranslationWorker, "concurrency") => { self.translation.worker.concurrency = Some(parse_translation_worker_concurrency( &parse_scalar_text(value, "translation.worker.concurrency", line_number)?, diff --git a/infrastructure/src/bin/bat/glossary_query.rs b/infrastructure/src/bin/bat/glossary_query.rs new file mode 100644 index 0000000..b69d6f6 --- /dev/null +++ b/infrastructure/src/bin/bat/glossary_query.rs @@ -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 { + 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> { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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, +) -> anyhow::Result { + 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, +) -> anyhow::Result { + 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, +) -> 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, 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, + key: &str, + method: &'static str, +) -> Result, 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, + method: &'static str, +) -> Result { + 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, + method: &'static str, +) -> Result { + 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) -> 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()) +} diff --git a/infrastructure/src/bin/bat/report_output.rs b/infrastructure/src/bin/bat/report_output.rs index 6625b2f..ee6ed51 100644 --- a/infrastructure/src/bin/bat/report_output.rs +++ b/infrastructure/src/bin/bat/report_output.rs @@ -115,9 +115,15 @@ impl HumanReport for bat_infrastructure::TranslationWorkerReport { print_field("TM 可用", format_bool(self.translation_memory_available)); print_field("TM 命中 TextUnit", self.translation_memory_hit_count); print_field("Provider TextUnit", self.provider_unit_count); + print_path_field("Glossary", &self.glossary_path); + print_field("Glossary 可用", format_bool(self.glossary_available)); + print_field("Glossary blocking TextUnit", self.glossary_blocked_count); for failure in &self.translation_memory_failures { println!(" - TM: {failure}"); } + for failure in &self.glossary_failures { + println!(" - Glossary: {failure}"); + } for failure in &self.failures { println!( " - {} [{}] retryable={} {}", diff --git a/infrastructure/src/bin/bat/terminal_output.rs b/infrastructure/src/bin/bat/terminal_output.rs index d15697d..0bd447c 100644 --- a/infrastructure/src/bin/bat/terminal_output.rs +++ b/infrastructure/src/bin/bat/terminal_output.rs @@ -345,6 +345,10 @@ Commands: i18n handoff Query current translation handoff i18n status Show localized release status for current official release i18n task update Update one provider worker task status + i18n glossary summary/query Show project Glossary terms and review counts + i18n glossary add/update Add or replace one Glossary term definition + i18n glossary approve/deprecate Review one Glossary term + i18n glossary diagnose Run deterministic Glossary QA for one TextUnit source i18n publish Publish a localized release from a workbench or worker results i18n rollback Roll back the current localized release i18n schedule Manage translation schedules @@ -384,6 +388,7 @@ Examples: {binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1 {binary} i18n proofread --json {binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s + {binary} i18n glossary diagnose --glossary-source-text Sensei --json {binary} i18n tasks --json {binary} i18n handoff --json {binary} i18n status --json @@ -433,6 +438,15 @@ Sync: --provider-run-id Provider run ID for i18n task update --translation-provider / --provider Provider for i18n worker run (mock/crowdin) --translation-fixture Mock/provider fixture for i18n worker run + --glossary-path Project Glossary SQLite path + --glossary-term-id Glossary term ID for add/update/review + --glossary-source-term Source spelling for a Glossary term + --glossary-recommended-translation Recommended target translation + --glossary-source-text Source TextUnit text for Glossary query/diagnose + --glossary-context-json TextUnit context for Glossary diagnose + --glossary-reviewer Reviewer for Glossary updates/reviews + --glossary-reason Reason for Glossary review or override + --glossary-provenance Provenance for an explicit Glossary override --worker-concurrency Translation worker concurrency (default: 8, range 1..=256) --worker-max-attempts Maximum claims per translation task --worker-lease-seconds Lease seconds for one claimed task diff --git a/infrastructure/src/bin/bat/translation_query.rs b/infrastructure/src/bin/bat/translation_query.rs index 1e17064..a58fee6 100644 --- a/infrastructure/src/bin/bat/translation_query.rs +++ b/infrastructure/src/bin/bat/translation_query.rs @@ -1,6 +1,6 @@ use super::report_output::print_json_value; use super::*; -use bat_core::domain::TranslationMemoryContext; +use bat_core::domain::{GlossaryOverride, TranslationMemoryContext}; use bat_core::repositories::TranslationMemoryRepository; use std::collections::BTreeMap; @@ -9,6 +9,8 @@ struct TranslationTaskResultUpdateParam { unit_id: String, source_text: String, translated_text: String, + #[serde(default)] + glossary_override: Option, } pub(super) fn build_translation_tasks_report( @@ -170,6 +172,7 @@ pub(super) fn build_translation_handoff_report( pub(super) fn update_translation_task_status_report( state_dir: &Path, params: Option<&serde_json::Value>, + configured_glossary_path: Option<&Path>, ) -> anyhow::Result { let task_id = rpc_string_param(params, "task_id") .ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?; @@ -229,6 +232,22 @@ pub(super) fn update_translation_task_status_report( .find(task_id) .await .map_err(|error| anyhow::anyhow!("{error}"))?; + let glossary_path = configured_glossary_path + .map(PathBuf::from) + .unwrap_or_else(|| { + bat_infrastructure::SqliteGlossaryRepository::repository_path( + ¤t.resource_root, + ) + }); + let glossary = if std::fs::symlink_metadata(&glossary_path).is_ok() { + Some( + bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path) + .await + .map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))?, + ) + } else { + None + }; let results = build_manual_translation_results( ¤t_task, index, @@ -236,7 +255,9 @@ pub(super) fn update_translation_task_status_report( &result_provider, &result_provider_run_id, result_timestamp, - )?; + glossary.as_ref(), + ) + .await?; repository .update_status_with_results( task_id, @@ -291,13 +312,14 @@ fn translation_task_result_params( .map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}")) } -fn build_manual_translation_results( +async fn build_manual_translation_results( task: &bat_infrastructure::PersistedTranslationTask, index: &bat_infrastructure::OfficialTextUnitIndex, params: &[TranslationTaskResultUpdateParam], provider: &str, provider_run_id: &str, translated_unix_seconds: u64, + glossary: Option<&bat_infrastructure::SqliteGlossaryRepository>, ) -> anyhow::Result> { let index_by_id = index .units @@ -332,6 +354,53 @@ fn build_manual_translation_results( "TextUnit {unit_id} 的 source_text 与当前索引不一致" )); } + let glossary_qa = if let Some(glossary) = glossary { + let context = bat_infrastructure::translation_memory_context( + &unit.destination, + unit.archive_entry.as_deref(), + unit.serialized_file.as_deref(), + unit.path_id, + unit.class_id, + unit.field_path.as_deref(), + unit.format.as_deref(), + unit.asset_name.as_deref(), + unit.text_source_kind.as_deref(), + &unit.context, + ); + Some( + glossary + .diagnose(&unit.source_text, &context) + .await + .map_err(|error| anyhow::anyhow!("Glossary QA 失败:{error}"))? + .check_translation(¶m.translated_text), + ) + } else { + None + }; + if let Some(qa) = glossary_qa.as_ref().filter(|qa| qa.status.is_blocked()) { + let Some(override_record) = param.glossary_override.as_ref() else { + return Err(anyhow::anyhow!( + "TextUnit {} 的 Glossary QA blocked;必须提供 glossary_override", + unit_id + )); + }; + if override_record.reviewer.trim().is_empty() + || override_record.reason.trim().is_empty() + || override_record.provenance.trim().is_empty() + || override_record.confirmed_unix_seconds == 0 + { + return Err(anyhow::anyhow!( + "TextUnit {} 的 glossary_override 不完整或 confirmed_unix_seconds 无效", + unit_id + )); + } + let _ = qa; + } else if param.glossary_override.is_some() { + return Err(anyhow::anyhow!( + "TextUnit {} 不能为非 blocking Glossary QA 指定 override", + unit_id + )); + } results.push(bat_infrastructure::TranslationTaskUnitResult { unit_id: unit_id.to_string(), source_text: param.source_text.clone(), @@ -341,6 +410,8 @@ fn build_manual_translation_results( provider: provider.to_string(), provider_run_id: provider_run_id.to_string(), translated_unix_seconds, + glossary_qa, + glossary_override: param.glossary_override.clone(), }); } Ok(results) diff --git a/infrastructure/src/bin/bat/workflow_commands.rs b/infrastructure/src/bin/bat/workflow_commands.rs index 1263dd5..5d84f4f 100644 --- a/infrastructure/src/bin/bat/workflow_commands.rs +++ b/infrastructure/src/bin/bat/workflow_commands.rs @@ -104,7 +104,12 @@ pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<( .ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?; let (resource_root, release_id) = current_official_release(options)?; let workbench = read_translation_workbench(path)?; - let validation = validate_translation_workbench(&resource_root, &release_id, &workbench)?; + let validation = validate_translation_workbench_with_glossary_path( + &resource_root, + &release_id, + &workbench, + options.glossary_path.as_deref(), + )?; let data = serde_json::json!({ "official_release_id": release_id, "resource_root": resource_root, @@ -141,7 +146,50 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> { .translation_id .as_deref() .ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?; - let entry = set_translation(path, entry_id, text)?; + let glossary_override = match ( + options.glossary_reviewer.as_deref(), + options.glossary_reason.as_deref(), + options.glossary_override_provenance.as_deref(), + ) { + (None, None, None) => None, + (Some(reviewer), Some(reason), Some(provenance)) => Some( + bat_core::domain::GlossaryOverride { + reviewer: reviewer.to_string(), + reason: reason.to_string(), + provenance: provenance.to_string(), + confirmed_unix_seconds: unix_seconds_now(), + }, + ), + _ => { + return Err(anyhow::anyhow!( + "Glossary override 必须同时指定 --glossary-reviewer、--glossary-reason 和 --glossary-provenance" + )) + } + }; + let workbench = read_translation_workbench(path)?; + let glossary_path = options.glossary_path.clone().unwrap_or_else(|| { + bat_infrastructure::SqliteGlossaryRepository::repository_path( + &workbench.official_resource_root, + ) + }); + let entry = if std::fs::symlink_metadata(&glossary_path).is_ok() { + let (resource_root, _) = current_official_release(options)?; + set_translation_checked_with_glossary_path( + &resource_root, + path, + entry_id, + text, + glossary_override, + options.glossary_path.as_deref(), + )? + } else { + if glossary_override.is_some() { + return Err(anyhow::anyhow!( + "当前项目没有 Glossary 数据库,不能提交 Glossary override" + )); + } + set_translation(path, entry_id, text)? + }; let data = serde_json::json!({ "translation_file": path, "entry": entry, @@ -232,6 +280,7 @@ pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Resul let report = update_translation_task_status_report( &options.state_dir, Some(&serde_json::Value::Object(params)), + options.glossary_path.as_deref(), )?; print_json_value(options.output_format, &report) } @@ -316,6 +365,12 @@ pub(super) fn publish_localized_report( "翻译工作台资源根目录与当前 release 不一致;请重新导出" )); } + validate_translation_workbench_with_glossary_path( + &resource_root, + &official_release_id, + &workbench, + options.glossary_path.as_deref(), + )?; let operations = localized_patch_operations(&resource_root, &workbench)?; let localized_release_id = options.localized_release_id.clone().or_else(|| { options diff --git a/infrastructure/src/glossary.rs b/infrastructure/src/glossary.rs new file mode 100644 index 0000000..7e6dffd --- /dev/null +++ b/infrastructure/src/glossary.rs @@ -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) -> Result { + Self::open_with(path.as_ref(), true).await + } + + /// Opens an existing glossary database without creating it. + pub async fn open(path: impl AsRef) -> Result { + 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 { + 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 = + 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 { + 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::>>()?; + 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, + limit: usize, + ) -> Result> { + 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 { + 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::("term_count").map_err(db_error)? as u64, + approved_count: row.try_get::("approved_count").map_err(db_error)? as u64, + draft_count: row.try_get::("draft_count").map_err(db_error)? as u64, + deprecated_count: row + .try_get::("deprecated_count") + .map_err(db_error)? as u64, + rejected_count: row.try_get::("rejected_count").map_err(db_error)? as u64, + }) + } + + /// Adds a term and records its source snapshot. + pub async fn add(&self, draft: GlossaryTermDraft) -> Result { + 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 = + 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, + ) -> Result { + 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, + ) -> Result { + 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 { + 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 { + 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 { + 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::("review_status") + .map_err(db_error)? + .as_str(), + )?, + source: GlossarySourceRecord { + source_kind: parse_source_kind( + row.try_get::("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 { + 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::("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(value: String) -> Result { + serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string())) +} + +fn json(value: &T) -> Result { + serde_json::to_string(value).map_err(|error| Error::Serialization(error.to_string())) +} + +fn parse_review_status(value: &str) -> Result { + GlossaryReviewStatus::parse(value) + .ok_or_else(|| Error::Serialization(format!("未知 Glossary review status:{value}"))) +} + +fn parse_source_kind(value: &str) -> Result { + GlossarySourceKind::parse(value) + .ok_or_else(|| Error::Serialization(format!("未知 Glossary source kind:{value}"))) +} + +fn to_u64(value: i64, label: &str) -> Result { + 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::("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); + } +} diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 5fd044b..343eb34 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -13,6 +13,7 @@ pub mod cas; mod curl_transfer; pub mod downloader; +pub mod glossary; pub mod import; pub mod localized_patch; pub mod official_changes; @@ -43,6 +44,10 @@ pub use downloader::{ DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY, }; +pub use glossary::{ + SqliteGlossaryRepository, GLOSSARY_REPOSITORY_FILE, GLOSSARY_SCHEMA_COMPONENT, + GLOSSARY_SCHEMA_VERSION, +}; pub use import::{ BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport, ResourceImportService, @@ -169,9 +174,11 @@ pub use translation_workflow::{ completed_worker_translation_workbench, export_completed_worker_translation_workbench, export_translation_workbench, get_translation_entry, localized_patch_operations, localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation, - unset_translation, validate_translation_workbench, write_translation_workbench, - RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, - TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION, + set_translation_checked, set_translation_checked_with_glossary_path, unset_translation, + validate_translation_workbench, validate_translation_workbench_with_glossary_path, + write_translation_workbench, RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, + TranslationWorkbenchEntry, TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, + TRANSLATION_WORKBENCH_VERSION, }; /// Infrastructure 版本号 diff --git a/infrastructure/src/localized_patch.rs b/infrastructure/src/localized_patch.rs index 043f2fd..cdd5204 100644 --- a/infrastructure/src/localized_patch.rs +++ b/infrastructure/src/localized_patch.rs @@ -99,6 +99,12 @@ pub struct LocalizedPatchOperationMetadata { pub translation_memory_record_id: Option, /// Review state used by the publication input. pub review_status: String, + /// Deterministic Glossary QA recorded for this translation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_qa: Option, + /// Explicit confirmation for a blocking Glossary deviation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_override: Option, } /// Configuration for one localized release publication. diff --git a/infrastructure/src/translation_tasks.rs b/infrastructure/src/translation_tasks.rs index 8a05d6a..d535d35 100644 --- a/infrastructure/src/translation_tasks.rs +++ b/infrastructure/src/translation_tasks.rs @@ -173,6 +173,12 @@ pub struct TranslationTaskUnitResult { pub provider_run_id: String, /// Result persistence time. pub translated_unix_seconds: u64, + /// Deterministic Glossary QA result, when a project Glossary was available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_qa: Option, + /// Explicit human confirmation for a blocking Glossary deviation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_override: Option, } /// Source of one persisted TextUnit translation result. @@ -1808,6 +1814,8 @@ mod tests { provider: "manual".to_string(), provider_run_id: "manual-run-1".to_string(), translated_unix_seconds: 321, + glossary_qa: None, + glossary_override: None, }; let updated = repository @@ -1927,6 +1935,8 @@ mod tests { provider: "mock".to_string(), provider_run_id: second_run.clone(), translated_unix_seconds: 1, + glossary_qa: None, + glossary_override: None, }; assert!(repository diff --git a/infrastructure/src/translation_worker.rs b/infrastructure/src/translation_worker.rs index 07d1584..cb72f76 100644 --- a/infrastructure/src/translation_worker.rs +++ b/infrastructure/src/translation_worker.rs @@ -5,6 +5,7 @@ //! 租约和任务结果写入 release 级 `translation-tasks.sqlite`,跨 release 的 //! Translation Memory 写入项目级独立 SQLite 数据库。 +use crate::glossary::SqliteGlossaryRepository; use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit}; use crate::official_textunit_queue::read_textunit_task_queue_at; use crate::translation_memory::{translation_memory_context, SqliteTranslationMemoryRepository}; @@ -14,9 +15,10 @@ use crate::translation_tasks::{ }; use async_trait::async_trait; use bat_core::domain::{ - TranslationMemoryDraft, TranslationMemorySourceKind, TranslationMemorySourceTrace, + GlossaryConstraint, GlossaryQaReport, TranslationMemoryDraft, TranslationMemorySourceKind, + TranslationMemorySourceTrace, }; -use bat_core::repositories::TranslationMemoryRepository; +use bat_core::repositories::{GlossaryRepository, TranslationMemoryRepository}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::env; @@ -98,6 +100,9 @@ pub struct TranslationWorkerConfig { /// Translation Memory SQLite path. `None` uses the output-root default. #[serde(skip_serializing_if = "Option::is_none")] pub translation_memory_path: Option, + /// Project-level Glossary SQLite path. `None` uses the output-root default. + #[serde(skip_serializing_if = "Option::is_none")] + pub glossary_path: Option, } impl Default for TranslationWorkerConfig { @@ -112,6 +117,7 @@ impl Default for TranslationWorkerConfig { max_tasks: None, worker_id: format!("bat-worker-{}", std::process::id()), translation_memory_path: None, + glossary_path: None, } } } @@ -180,6 +186,9 @@ pub struct TranslationProviderUnit { /// 解析器保留的上下文,包括可选 `crowdin_string_id`。 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub context: BTreeMap, + /// Approved Glossary constraints for this TextUnit. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub glossary_constraints: Vec, } /// 一次 provider 批处理请求。 @@ -596,6 +605,14 @@ pub struct TranslationWorkerReport { pub provider_unit_count: usize, /// Translation Memory diagnostics that did not invalidate provider work. pub translation_memory_failures: Vec, + /// 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, } /// worker 失败诊断。 @@ -623,6 +640,8 @@ struct WorkerStats { provider_unit_count: AtomicUsize, failures: Mutex>, translation_memory_failures: Mutex>, + glossary_blocked_count: AtomicUsize, + glossary_failures: Mutex>, } struct WorkerTaskContext<'a> { @@ -635,6 +654,7 @@ struct WorkerTaskContext<'a> { retry_backoff: Duration, stats: &'a WorkerStats, translation_memory: Option<&'a dyn TranslationMemoryRepository>, + glossary: Option<&'a dyn GlossaryRepository>, } /// 运行一个 provider worker 轮次。 @@ -715,6 +735,28 @@ async fn run_translation_worker_with_provider_and_cancellation( )), ), }; + let glossary_path = config + .glossary_path + .clone() + .unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root)); + let (glossary, glossary_startup_failure) = if std::fs::symlink_metadata(&glossary_path).is_ok() + { + match SqliteGlossaryRepository::open(&glossary_path).await { + Ok(repository) => (Some(Arc::new(repository)), None), + Err(error) => ( + None, + Some(format!( + "打开 Glossary 数据库失败 {}:{error}", + glossary_path.display() + )), + ), + } + } else { + (None, None) + }; + if let Some(failure) = glossary_startup_failure.as_deref() { + return Err(anyhow::anyhow!(failure.to_string())); + } let repository = Arc::new( SqliteTranslationTaskRepository::new(SqliteTranslationTaskRepository::repository_path( resource_root, @@ -754,6 +796,7 @@ async fn run_translation_worker_with_provider_and_cancellation( let lease_seconds = config.lease_seconds; let retry_backoff = config.retry_backoff; let translation_memory = translation_memory.clone(); + let glossary = glossary.clone(); let should_cancel = Arc::clone(&should_cancel); handles.push(tokio::spawn(async move { loop { @@ -791,6 +834,9 @@ async fn run_translation_worker_with_provider_and_cancellation( translation_memory: translation_memory .as_deref() .map(|repository| repository as &dyn TranslationMemoryRepository), + glossary: glossary + .as_deref() + .map(|repository| repository as &dyn GlossaryRepository), }, &task, ) @@ -842,6 +888,11 @@ async fn run_translation_worker_with_provider_and_cancellation( .lock() .map_err(|_| anyhow::anyhow!("读取 Translation Memory 诊断时 mutex poisoned"))? .clone(); + let glossary_failures = stats + .glossary_failures + .lock() + .map_err(|_| anyhow::anyhow!("读取 Glossary 诊断时 mutex poisoned"))? + .clone(); Ok(TranslationWorkerReport { command: "translation-worker", status: if failed_count == 0 { @@ -863,6 +914,10 @@ async fn run_translation_worker_with_provider_and_cancellation( translation_memory_hit_count: stats.translation_memory_hit_count.load(Ordering::Relaxed), provider_unit_count: stats.provider_unit_count.load(Ordering::Relaxed), translation_memory_failures, + glossary_path, + glossary_available: glossary.is_some(), + glossary_blocked_count: stats.glossary_blocked_count.load(Ordering::Relaxed), + glossary_failures, }) } @@ -873,35 +928,99 @@ async fn process_claimed_task( let task_units = task_index_units(task, context.index)?; let mut results = BTreeMap::new(); let mut provider_units = Vec::new(); + let mut glossary_evaluations = BTreeMap::new(); for unit in &task_units { + let source_context = translation_memory_context( + &unit.destination, + unit.archive_entry.as_deref(), + unit.serialized_file.as_deref(), + unit.path_id, + unit.class_id, + unit.field_path.as_deref(), + unit.format.as_deref(), + unit.asset_name.as_deref(), + unit.text_source_kind.as_deref(), + &unit.context, + ); + let glossary_evaluation = if let Some(glossary) = context.glossary { + match glossary.evaluate(&unit.source_text, &source_context).await { + Ok(evaluation) => evaluation, + Err(error) => { + record_glossary_failure( + context, + format!( + "任务 {} TextUnit {} 查询失败:{}", + task.task.task_id, unit.id, error + ), + )?; + record_provider_failure( + context, + task, + TranslationProviderError::new( + TranslationProviderFailureClass::InvalidRequest, + format!("TextUnit {} 无法完成 Glossary QA;自动翻译已阻止", unit.id), + ), + &results, + ) + .await?; + return Ok(()); + } + } + } else { + bat_core::domain::GlossaryEvaluation { + constraints: Vec::new(), + diagnostics: Vec::new(), + blocked: false, + } + }; + if glossary_evaluation.blocked { + context + .stats + .glossary_blocked_count + .fetch_add(1, Ordering::Relaxed); + record_provider_failure( + context, + task, + TranslationProviderError::new( + TranslationProviderFailureClass::InvalidRequest, + format!( + "TextUnit {} 的 Glossary 存在未解决冲突,必须人工确认后才能继续", + unit.id + ), + ), + &results, + ) + .await?; + return Ok(()); + } + glossary_evaluations.insert(unit.id.clone(), glossary_evaluation); if let Some(translation_memory) = context.translation_memory { - let source_context = translation_memory_context( - &unit.destination, - unit.archive_entry.as_deref(), - unit.serialized_file.as_deref(), - unit.path_id, - unit.class_id, - unit.field_path.as_deref(), - unit.format.as_deref(), - unit.asset_name.as_deref(), - unit.text_source_kind.as_deref(), - &unit.context, - ); match translation_memory .find_matches(&unit.source_text, &source_context, 1) .await { Ok(matches) => { if let Some(found) = matches.into_iter().find(|item| item.can_auto_reuse) { - context - .stats - .translation_memory_hit_count - .fetch_add(1, Ordering::Relaxed); - results.insert( - unit.id.clone(), - translation_memory_result(task, unit, &found.entry), - ); - continue; + let qa = glossary_evaluations + .get(&unit.id) + .expect("Glossary evaluation inserted before TM lookup") + .check_translation(&found.entry.translated_text); + if qa.status.is_blocked() { + context + .stats + .glossary_blocked_count + .fetch_add(1, Ordering::Relaxed); + } else { + context + .stats + .translation_memory_hit_count + .fetch_add(1, Ordering::Relaxed); + results.insert( + unit.id.clone(), + translation_memory_result(task, unit, &found.entry, qa), + ); + continue; + } } } Err(error) => { @@ -923,7 +1042,7 @@ async fn process_claimed_task( .stats .provider_unit_count .fetch_add(provider_units.len(), Ordering::Relaxed); - let request = match provider_request(task, &provider_units) { + let request = match provider_request(task, &provider_units, &glossary_evaluations) { Ok(request) => request, Err(error) => { record_provider_failure( @@ -941,23 +1060,48 @@ async fn process_claimed_task( }; match context.provider.translate(request.clone()).await { Ok(response) => { - let provider_results = - match validate_provider_response(&request, response, context.provider_name) { - Ok(results) => results, - Err(error) => { - record_provider_failure( - context, - task, - TranslationProviderError::new( - TranslationProviderFailureClass::InvalidRequest, - error.to_string(), - ), - &results, - ) - .await?; - return Ok(()); - } - }; + let provider_results = match validate_provider_response( + &request, + response, + context.provider_name, + &glossary_evaluations, + ) { + Ok(results) => results, + Err(error) => { + record_provider_failure( + context, + task, + TranslationProviderError::new( + TranslationProviderFailureClass::InvalidRequest, + error.to_string(), + ), + &results, + ) + .await?; + return Ok(()); + } + }; + if provider_results.iter().any(|result| { + result + .glossary_qa + .as_ref() + .is_some_and(|qa| qa.status.is_blocked()) + }) { + for result in &provider_results { + results.insert(result.unit_id.clone(), result.clone()); + } + record_provider_failure( + context, + task, + TranslationProviderError::new( + TranslationProviderFailureClass::InvalidRequest, + "provider 译文未通过 Glossary QA;需要人工 override 后才能发布", + ), + &results, + ) + .await?; + return Ok(()); + } for result in &provider_results { results.insert(result.unit_id.clone(), result.clone()); if let Some(unit) = provider_units.iter().find(|unit| unit.id == result.unit_id) @@ -986,6 +1130,13 @@ async fn process_claimed_task( observed_unix_seconds: unix_seconds_now(), }; if let Some(translation_memory) = context.translation_memory { + if results + .get(&result.unit_id) + .and_then(|value| value.glossary_qa.as_ref()) + .is_some_and(|qa| qa.status.is_blocked()) + { + continue; + } if let Err(error) = translation_memory.upsert_candidate(draft).await { record_translation_memory_failure( context, @@ -1066,6 +1217,7 @@ fn task_index_units<'a>( fn provider_request( task: &PersistedTranslationTask, index_units: &[&OfficialTextUnitIndexUnit], + glossary_evaluations: &BTreeMap, ) -> anyhow::Result { let provider_run_id = task .provider_run_id @@ -1079,7 +1231,16 @@ fn provider_request( archive_entry: task.task.archive_entry.clone(), units: index_units .iter() - .map(|unit| provider_unit(task, unit)) + .map(|unit| { + provider_unit( + task, + unit, + glossary_evaluations + .get(&unit.id) + .map(|evaluation| evaluation.constraints.clone()) + .unwrap_or_default(), + ) + }) .collect(), }) } @@ -1087,6 +1248,7 @@ fn provider_request( fn provider_unit( task: &PersistedTranslationTask, unit: &OfficialTextUnitIndexUnit, + glossary_constraints: Vec, ) -> TranslationProviderUnit { TranslationProviderUnit { unit_id: unit.id.clone(), @@ -1103,6 +1265,7 @@ fn provider_unit( text_source_kind: unit.text_source_kind.clone(), asset_name: unit.asset_name.clone(), context: unit.context.clone(), + glossary_constraints, } } @@ -1110,6 +1273,7 @@ fn validate_provider_response( request: &TranslationProviderRequest, response: TranslationProviderResponse, provider_name: &str, + glossary_evaluations: &BTreeMap, ) -> anyhow::Result> { if response.provider_run_id != request.provider_run_id { return Err(anyhow::anyhow!( @@ -1150,6 +1314,9 @@ fn validate_provider_response( result.unit_id )); } + let glossary_qa = glossary_evaluations + .get(&result.unit_id) + .map(|evaluation| evaluation.check_translation(&result.translated_text)); results.push(TranslationTaskUnitResult { unit_id: result.unit_id, source_text: result.source_text, @@ -1159,6 +1326,8 @@ fn validate_provider_response( provider: provider_name.to_string(), provider_run_id: request.provider_run_id.clone(), translated_unix_seconds: unix_seconds_now(), + glossary_qa, + glossary_override: None, }); } if seen.len() != expected.len() { @@ -1175,6 +1344,7 @@ fn translation_memory_result( task: &PersistedTranslationTask, unit: &OfficialTextUnitIndexUnit, entry: &bat_core::domain::TranslationMemoryEntry, + glossary_qa: GlossaryQaReport, ) -> TranslationTaskUnitResult { TranslationTaskUnitResult { unit_id: unit.id.clone(), @@ -1185,6 +1355,8 @@ fn translation_memory_result( provider: "translation_memory".to_string(), provider_run_id: task.provider_run_id.clone().unwrap_or_default(), translated_unix_seconds: unix_seconds_now(), + glossary_qa: Some(glossary_qa), + glossary_override: None, } } @@ -1222,6 +1394,16 @@ fn record_translation_memory_failure( Ok(()) } +fn record_glossary_failure(context: &WorkerTaskContext<'_>, message: String) -> anyhow::Result<()> { + context + .stats + .glossary_failures + .lock() + .map_err(|_| anyhow::anyhow!("写入 Glossary 诊断时 mutex poisoned"))? + .push(message); + Ok(()) +} + async fn record_provider_failure( context: &WorkerTaskContext<'_>, task: &PersistedTranslationTask, @@ -1455,6 +1637,122 @@ mod tests { assert_eq!(task.translation_results[0].translated_text, "translated-0"); } + #[tokio::test] + async fn worker_sends_approved_glossary_constraints_and_persists_qa() { + let (temp, queue) = fixture_root(); + let textunit_index = index(temp.path()); + crate::official_textunit_queue::write_textunit_task_queue_at(temp.path(), &queue).unwrap(); + crate::official_parse::write_textunit_index_at(temp.path(), &textunit_index).unwrap(); + + let glossary_path = temp.path().join("glossary.sqlite"); + let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap(); + glossary + .add(bat_core::domain::GlossaryTermDraft { + term_id: "term-source-0".to_string(), + definition: bat_core::domain::GlossaryTermSnapshot { + source_term: "source-0".to_string(), + aliases: Vec::new(), + recommended_translation: "term-0".to_string(), + allowed_translations: Vec::new(), + source_language: Some("en".to_string()), + target_language: Some("zh-Hans".to_string()), + category: Some("test".to_string()), + priority: 10, + scope: BTreeMap::new(), + }, + review_status: bat_core::domain::GlossaryReviewStatus::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>>, + } + + #[async_trait::async_trait] + impl TranslationProvider for GlossaryProvider { + fn name(&self) -> &'static str { + "glossary-test" + } + + async fn translate( + &self, + request: TranslationProviderRequest, + ) -> Result { + self.requests.lock().unwrap().push(request.clone()); + Ok(TranslationProviderResponse { + provider_run_id: request.provider_run_id, + units: request + .units + .into_iter() + .map(|unit| TranslationProviderUnitResult { + unit_id: unit.unit_id, + source_text: unit.source_text.clone(), + translated_text: if unit.source_text == "source-0" { + "term-0".to_string() + } else { + "translated-1".to_string() + }, + }) + .collect(), + }) + } + } + + let requests = Arc::new(Mutex::new(Vec::new())); + let config = TranslationWorkerConfig { + glossary_path: Some(glossary_path), + concurrency: 1, + retry_backoff: Duration::ZERO, + ..TranslationWorkerConfig::default() + }; + let report = run_translation_worker_with_provider( + temp.path(), + &config, + Arc::new(GlossaryProvider { + requests: Arc::clone(&requests), + }), + ) + .await + .unwrap(); + + assert_eq!(report.completed_count, 1); + assert!(report.glossary_available); + assert_eq!(report.glossary_blocked_count, 0); + { + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].units[0].glossary_constraints.len(), 1); + assert_eq!( + requests[0].units[0].glossary_constraints[0].term_id, + "term-source-0" + ); + } + + let repository = SqliteTranslationTaskRepository::open( + SqliteTranslationTaskRepository::repository_path(temp.path()), + ) + .await + .unwrap(); + let task = repository.find(&queue.tasks[0].task_id).await.unwrap(); + assert_eq!(task.translation_results[0].translated_text, "term-0"); + assert_eq!( + task.translation_results[0] + .glossary_qa + .as_ref() + .map(|qa| qa.status), + Some(bat_core::domain::GlossaryQaStatus::Pass) + ); + } + #[tokio::test] async fn worker_honors_cancellation_before_claiming_tasks() { let (temp, queue) = fixture_root(); diff --git a/infrastructure/src/translation_workflow.rs b/infrastructure/src/translation_workflow.rs index 8eb6811..70e2228 100644 --- a/infrastructure/src/translation_workflow.rs +++ b/infrastructure/src/translation_workflow.rs @@ -1,5 +1,6 @@ //! Manual translation workbench and controlled UnityFS repack workflows. +use crate::glossary::SqliteGlossaryRepository; use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit}; use crate::official_textunit_queue::OfficialTextUnitTaskQuery; use crate::path_security::{ @@ -15,6 +16,7 @@ use bat_assetbundle::{ patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue, }; +use bat_core::domain::{GlossaryOverride, GlossaryQaReport}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; @@ -90,6 +92,12 @@ pub struct TranslationWorkbenchEntry { /// Extraction source kind such as TextAsset or TypeTreeField. #[serde(default, skip_serializing_if = "Option::is_none")] pub text_source_kind: Option, + /// Deterministic Glossary QA for the current translation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_qa: Option, + /// Explicit human confirmation for a blocking Glossary deviation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_override: Option, } /// Summary produced by `i18n validate`. @@ -274,6 +282,73 @@ pub fn set_translation( .find(|entry| entry.id == entry_id) .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?; entry.translated_text = Some(translated_text); + entry.glossary_qa = None; + entry.glossary_override = None; + let updated = entry.clone(); + workbench.generated_unix_seconds = unix_seconds_now(); + write_translation_workbench(workbench_path, &workbench)?; + Ok(updated) +} + +/// Updates one translation and evaluates the project Glossary. +pub fn set_translation_checked( + resource_root: &Path, + workbench_path: &Path, + entry_id: &str, + translated_text: String, + glossary_override: Option, +) -> anyhow::Result { + 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, + configured_glossary_path: Option<&Path>, +) -> anyhow::Result { + let mut workbench = read_translation_workbench(workbench_path)?; + let current = read_textunit_index_at(resource_root) + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))? + .units + .into_iter() + .find(|unit| unit.id == entry_id) + .ok_or_else(|| anyhow::anyhow!("当前 release 不存在 TextUnit:{entry_id}"))?; + let glossary_path = configured_glossary_path + .map(PathBuf::from) + .unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root)); + let glossary = open_glossary_if_present(&glossary_path)?; + let qa = glossary + .as_ref() + .map(|glossary| evaluate_glossary_entry(glossary, ¤t, &translated_text)) + .transpose()?; + if let Some(qa) = qa.as_ref().filter(|qa| qa.status.is_blocked()) { + validate_glossary_override(glossary_override.as_ref())?; + let _ = qa; + } else if glossary_override.is_some() { + return Err(anyhow::anyhow!( + "Glossary override 只能用于存在 blocking QA 的译文" + )); + } + let entry = workbench + .entries + .iter_mut() + .find(|entry| entry.id == entry_id) + .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?; + entry.translated_text = Some(translated_text); + entry.glossary_qa = qa; + entry.glossary_override = glossary_override; let updated = entry.clone(); workbench.generated_unix_seconds = unix_seconds_now(); write_translation_workbench(workbench_path, &workbench)?; @@ -305,6 +380,8 @@ pub fn unset_translation( .find(|entry| entry.id == entry_id) .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?; entry.translated_text = None; + entry.glossary_qa = None; + entry.glossary_override = None; let updated = entry.clone(); workbench.generated_unix_seconds = unix_seconds_now(); write_translation_workbench(workbench_path, &workbench)?; @@ -320,6 +397,21 @@ pub fn validate_translation_workbench( resource_root: &Path, official_release_id: &str, workbench: &TranslationWorkbench, +) -> anyhow::Result { + 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 { let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?; if workbench.official_release_id != official_release_id { @@ -349,6 +441,10 @@ pub fn validate_translation_workbench( let mut changed_entries = 0; let mut publishable_entries = 0; let mut repack_entries = 0; + let glossary_path = configured_glossary_path + .map(PathBuf::from) + .unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root)); + let glossary = open_glossary_if_present(&glossary_path)?; for entry in &workbench.entries { if !seen_ids.insert(entry.id.as_str()) { @@ -366,6 +462,12 @@ pub fn validate_translation_workbench( unchanged_entries += 1; continue; } + if let Some(glossary) = glossary.as_ref() { + let qa = evaluate_glossary_entry(glossary, current, translated_text)?; + if qa.status.is_blocked() { + validate_glossary_override(entry.glossary_override.as_ref())?; + } + } changed_entries += 1; let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref()); let is_publishable = entry.archive_entry.is_none() @@ -419,6 +521,63 @@ pub fn validate_translation_workbench( }) } +fn open_glossary_if_present(path: &Path) -> anyhow::Result> { + 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 { + let context = crate::translation_memory::translation_memory_context( + &unit.destination, + unit.archive_entry.as_deref(), + unit.serialized_file.as_deref(), + unit.path_id, + unit.class_id, + unit.field_path.as_deref(), + unit.format.as_deref(), + unit.asset_name.as_deref(), + unit.text_source_kind.as_deref(), + &unit.context, + ); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime + .block_on(glossary.diagnose(&unit.source_text, &context)) + .map(|evaluation| evaluation.check_translation(translated_text)) + .map_err(|error| anyhow::anyhow!("执行 Glossary QA 失败:{error}")) +} + +fn validate_glossary_override(glossary_override: Option<&GlossaryOverride>) -> anyhow::Result<()> { + let Some(glossary_override) = glossary_override else { + return Err(anyhow::anyhow!( + "Glossary QA blocked;需要 reviewer、reason 和 provenance 显式确认" + )); + }; + if glossary_override.reviewer.trim().is_empty() + || glossary_override.reason.trim().is_empty() + || glossary_override.provenance.trim().is_empty() + || glossary_override.confirmed_unix_seconds == 0 + { + return Err(anyhow::anyhow!( + "Glossary override 的 reviewer、reason、provenance 和 confirmed_unix_seconds 必须有效" + )); + } + Ok(()) +} + /// Converts reviewed entries to localized patch operations supported by the /// current UnityFS write layer. /// @@ -628,6 +787,8 @@ fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatch .review_status .clone() .unwrap_or_else(|| "manual_reviewed".to_string()), + glossary_qa: entry.glossary_qa.clone(), + glossary_override: entry.glossary_override.clone(), } } @@ -911,6 +1072,8 @@ fn workbench_entry_from_worker_result( entry.translation_source_kind = Some(result.source_kind.as_str().to_string()); entry.translation_memory_record_id = result.translation_memory_record_id.clone(); entry.translated_unix_seconds = Some(result.translated_unix_seconds); + entry.glossary_qa = result.glossary_qa.clone(); + entry.glossary_override = result.glossary_override.clone(); entry.review_status = Some( match result.source_kind { TranslationTaskResultSourceKind::Provider => "provider_completed", @@ -964,6 +1127,8 @@ impl TranslationWorkbenchEntry { review_status: None, format: unit.format.clone(), text_source_kind: unit.text_source_kind.clone(), + glossary_qa: None, + glossary_override: None, } } } @@ -1003,6 +1168,8 @@ mod tests { review_status: None, format: Some("plain".to_string()), text_source_kind: Some("text_asset".to_string()), + glossary_qa: None, + glossary_override: None, }], } } diff --git a/internal/api/admin.go b/internal/api/admin.go index 23e52b8..c0ed38f 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -56,6 +56,9 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/translation/handoff", "/admin/translation/memory/summary", "/admin/translation/memory/query", + "/admin/translation/glossary/summary", + "/admin/translation/glossary/query", + "/admin/translation/glossary/diagnose", "/admin/translation/status", }, Controls: []string{ @@ -75,6 +78,10 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/control/translation-worker-run", "/admin/control/translation-proofread", "/admin/control/translation-memory-confirm", + "/admin/control/translation-glossary-add", + "/admin/control/translation-glossary-update", + "/admin/control/translation-glossary-approve", + "/admin/control/translation-glossary-deprecate", "/admin/control/localized-publish", "/admin/control/localized-rollback", }, @@ -124,6 +131,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { s.handleAdminTranslationMemoryConfirm(w, r) return } + if strings.HasPrefix(action, "translation-glossary-") { + s.handleAdminGlossaryControl(w, r, action) + return + } if action == "localized-publish" { s.handleAdminLocalizedPublish(w, r) return @@ -309,6 +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) { backend, ok := s.backend.(LocalizedBackend) if !ok || backend == nil { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index cc881b7..47f38ae 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -637,6 +637,11 @@ type controlBackend struct { translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams + glossarySummaryParams []backendrpc.GlossarySummaryParams + glossaryQueryParams []backendrpc.GlossaryQueryParams + glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams + glossaryMutationParams []backendrpc.GlossaryTermMutationParams + glossaryReviewParams []backendrpc.GlossaryReviewParams localizedPublishParams []backendrpc.LocalizedPublishParams localizedRollbackParams []backendrpc.LocalizedRollbackParams } @@ -825,6 +830,81 @@ func (b *controlBackend) TranslationMemoryConfirm(ctx context.Context, params ba }, nil } +func (b *controlBackend) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) { + b.calls = append(b.calls, "translation.glossary.summary") + b.glossarySummaryParams = append(b.glossarySummaryParams, params) + schemaVersion := uint64(1) + return &backendrpc.GlossarySummaryReport{ + Available: true, + Path: params.GlossaryPath, + SchemaVersion: &schemaVersion, + Summary: &backendrpc.GlossarySummary{ + SchemaVersion: schemaVersion, + TermCount: 2, + ApprovedCount: 1, + DraftCount: 1, + }, + }, nil +} + +func (b *controlBackend) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) { + b.calls = append(b.calls, "translation.glossary.query") + b.glossaryQueryParams = append(b.glossaryQueryParams, params) + return &backendrpc.GlossaryQueryReport{ + Available: true, + Path: params.GlossaryPath, + Terms: []backendrpc.GlossaryTerm{{ + TermID: "term-sensei", + GlossaryTermSnapshot: backendrpc.GlossaryTermSnapshot{ + SourceTerm: "Sensei", + RecommendedTranslation: "老师", + Priority: 10, + }, + ReviewStatus: backendrpc.GlossaryStatusApproved, + Source: backendrpc.GlossarySourceRecord{ + SourceKind: "manual", + ObservedUnixSeconds: 100, + }, + }}, + }, nil +} + +func (b *controlBackend) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) { + b.calls = append(b.calls, "translation.glossary.diagnose") + b.glossaryDiagnoseParams = append(b.glossaryDiagnoseParams, params) + return &backendrpc.GlossaryDiagnoseReport{ + Available: true, + Path: params.GlossaryPath, + SourceText: params.SourceText, + Context: params.Context, + Evaluation: json.RawMessage(`{"constraints":[],"diagnostics":[],"blocked":false}`), + }, nil +} + +func (b *controlBackend) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) { + b.calls = append(b.calls, "translation.glossary.add") + b.glossaryMutationParams = append(b.glossaryMutationParams, params) + return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil +} + +func (b *controlBackend) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) { + b.calls = append(b.calls, "translation.glossary.update") + b.glossaryMutationParams = append(b.glossaryMutationParams, params) + return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil +} + +func (b *controlBackend) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) { + b.calls = append(b.calls, "translation.glossary.approve") + b.glossaryReviewParams = append(b.glossaryReviewParams, params) + return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil +} + +func (b *controlBackend) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) { + b.calls = append(b.calls, "translation.glossary.deprecate") + b.glossaryReviewParams = append(b.glossaryReviewParams, params) + return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil +} + func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { b.calls = append(b.calls, "localized.status") return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil @@ -1044,6 +1124,48 @@ func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T) t.Fatalf("missing TM query source status=%d body=%s", recorder.Code, recorder.Body.String()) } + request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/summary?glossary_path=%2Fvar%2Flib%2Fbat%2Fglossary.sqlite", nil) + request.Header.Set("Authorization", "Bearer translation-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK || + !strings.Contains(recorder.Body.String(), `"approved_count":1`) || + len(backend.glossarySummaryParams) != 1 || + backend.glossarySummaryParams[0].GlossaryPath != "/var/lib/bat/glossary.sqlite" { + t.Fatalf("Glossary summary status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossarySummaryParams) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/query?source_text=Sensei&limit=20", nil) + request.Header.Set("Authorization", "Bearer translation-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK || + len(backend.glossaryQueryParams) != 1 || + backend.glossaryQueryParams[0].SourceText != "Sensei" || + backend.glossaryQueryParams[0].Limit == nil || + *backend.glossaryQueryParams[0].Limit != 20 { + t.Fatalf("Glossary query status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryQueryParams) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose?source_text=Sensei&context=%7B%22destination%22%3A%22Bundle%2Fdialogue.bundle%22%7D", nil) + request.Header.Set("Authorization", "Bearer translation-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK || + len(backend.glossaryDiagnoseParams) != 1 || + backend.glossaryDiagnoseParams[0].SourceText != "Sensei" || + backend.glossaryDiagnoseParams[0].Context["destination"] != "Bundle/dialogue.bundle" { + t.Fatalf("Glossary diagnose status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryDiagnoseParams) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose", nil) + request.Header.Set("Authorization", "Bearer translation-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("missing Glossary diagnose source status=%d body=%s", recorder.Code, recorder.Body.String()) + } + request = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks?limit=0", nil) request.Header.Set("Authorization", "Bearer translation-token") recorder = httptest.NewRecorder() @@ -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 proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"}, {name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"}, + {name: "translation glossary add", action: "translation-glossary-add", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.add", call: "translation.glossary.add"}, + {name: "translation glossary update", action: "translation-glossary-update", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","reviewer":"reviewer","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.update", call: "translation.glossary.update"}, + {name: "translation glossary approve", action: "translation-glossary-approve", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"approved"}`, rpcMethod: "translation.glossary.approve", call: "translation.glossary.approve"}, + {name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"}, {name: "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"}, } @@ -1854,6 +1980,13 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" { t.Fatalf("TM confirm params=%#v", backend.translationMemoryConfirmParams) } + if len(backend.glossaryMutationParams) != 2 || + backend.glossaryMutationParams[0].TermID != "term-sensei" || + backend.glossaryMutationParams[1].Reviewer != "reviewer" || + len(backend.glossaryReviewParams) != 2 || + backend.glossaryReviewParams[0].TermID != "term-sensei" { + 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.Header.Set("Authorization", "Bearer control-token") diff --git a/internal/api/contract_fixture_test.go b/internal/api/contract_fixture_test.go index 2a1adf6..b36dcd0 100644 --- a/internal/api/contract_fixture_test.go +++ b/internal/api/contract_fixture_test.go @@ -198,3 +198,44 @@ func TestTranslationMemoryRustContractMirror(t *testing.T) { t.Fatalf("missing TM summary=%+v", missing) } } + +func TestGlossaryRustContractMirror(t *testing.T) { + raw := readContractFixture(t, "glossary-query.json") + if bytes.Contains(raw, []byte("/tmp/")) { + t.Fatal("Glossary mirror contains a local temporary path") + } + var report backendrpc.GlossaryQueryReport + if err := json.Unmarshal(raw, &report); err != nil { + t.Fatalf("decode Glossary query mirror: %v", err) + } + if !report.Available || report.Path != "${GLOSSARY_PATH}" || + report.SourceText != "${SOURCE_TEXT}" || len(report.Terms) != 1 { + t.Fatalf("Glossary report=%+v", report) + } + term := report.Terms[0] + if term.TermID != "${TERM_ID}" || + term.SourceTerm != "${SOURCE_TERM}" || + term.RecommendedTranslation != "${RECOMMENDED_TRANSLATION}" || + term.ReviewStatus != backendrpc.GlossaryStatusApproved || + term.Source.SourceKind != "manual" || + term.Source.ObservedUnixSeconds != 100 || + len(term.History) != 2 || + term.History[0].ReviewStatus != backendrpc.GlossaryStatusDraft || + term.History[1].Action != "approved" || + term.History[1].ReviewStatus != backendrpc.GlossaryStatusApproved || + term.History[1].Snapshot.Priority != 10 { + t.Fatalf("Glossary term=%+v", term) + } + var missing backendrpc.GlossarySummaryReport + if err := json.Unmarshal([]byte(`{ + "available": false, + "path": "${GLOSSARY_PATH}", + "reason": "database_missing" + }`), &missing); err != nil { + t.Fatalf("decode missing Glossary summary mirror: %v", err) + } + if missing.Available || missing.Summary != nil || missing.SchemaVersion != nil || + missing.Reason != "database_missing" { + t.Fatalf("missing Glossary summary=%+v", missing) + } +} diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 750481a..846722c 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -461,6 +461,86 @@ paths: description: Missing or invalid admin token. "503": description: Rust bat Translation Memory backend is unavailable. + /admin/translation/glossary/summary: + get: + summary: Read Rust-owned Glossary summary + parameters: + - name: glossary_path + in: query + schema: + type: string + responses: + "200": + description: Glossary availability and review-state counts. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Glossary backend is unavailable. + /admin/translation/glossary/query: + get: + summary: Query Rust-owned Glossary terms + parameters: + - name: source_text + in: query + schema: + type: string + - name: category + in: query + schema: + type: string + - name: review_status + in: query + schema: + type: string + enum: [draft, approved, deprecated, rejected] + - name: limit + in: query + schema: + type: integer + format: int64 + minimum: 1 + maximum: 1000 + default: 100 + - name: glossary_path + in: query + schema: + type: string + responses: + "200": + description: Glossary terms with source and review history. + "400": + description: Invalid Glossary query. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Glossary backend is unavailable. + /admin/translation/glossary/diagnose: + get: + summary: Run deterministic Glossary diagnostics + parameters: + - name: source_text + in: query + required: true + schema: + type: string + - name: context + in: query + description: JSON object whose values are strings. + schema: + type: string + - name: glossary_path + in: query + schema: + type: string + responses: + "200": + description: Glossary constraints, diagnostics, and blocked decision. + "400": + description: Missing source text or invalid context. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat Glossary backend is unavailable. /admin/translation/status: get: summary: Read Rust-owned localized release status @@ -480,7 +560,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, localized-publish, localized-rollback] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, localized-publish, localized-rollback] requestBody: required: false content: @@ -573,8 +653,57 @@ paths: type: string translation_memory_path: type: string + glossary_path: + type: string record_id: type: string + term_id: + type: string + source_term: + type: string + aliases: + type: array + items: + type: string + recommended_translation: + type: string + allowed_translations: + type: array + items: + type: string + source_language: + type: string + target_language: + type: string + category: + type: string + priority: + type: integer + format: int64 + scope: + type: object + additionalProperties: + type: string + review_status: + type: string + enum: [draft, approved, deprecated, rejected] + source: + type: object + additionalProperties: false + required: [source_kind, observed_unix_seconds] + properties: + source_kind: + type: string + enum: [manual, imported] + source_ref: + type: string + source_author: + type: string + source_note: + type: string + observed_unix_seconds: + type: integer + format: int64 reviewer: type: string reason: diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index cb5feab..05b1e2c 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -92,6 +92,18 @@ type TranslationMemoryBackend interface { TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error) } +// GlossaryBackend exposes Rust-owned Glossary management and diagnostics. +// Go forwards these typed calls and never opens the Glossary database. +type GlossaryBackend interface { + GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) + GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) + GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) + GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) + GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) + GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) + GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) +} + // LocalizedBackend exposes localized release status and the explicit // publish/rollback controls used by the authenticated dashboard. type LocalizedBackend interface { @@ -203,6 +215,28 @@ func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendr return r.Client.TranslationMemoryConfirm(ctx, params) } +func (r RPCClient) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) { + return r.Client.GlossarySummary(ctx, params) +} +func (r RPCClient) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) { + return r.Client.GlossaryQuery(ctx, params) +} +func (r RPCClient) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) { + return r.Client.GlossaryDiagnose(ctx, params) +} +func (r RPCClient) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) { + return r.Client.GlossaryAdd(ctx, params) +} +func (r RPCClient) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) { + return r.Client.GlossaryUpdate(ctx, params) +} +func (r RPCClient) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) { + return r.Client.GlossaryApprove(ctx, params) +} +func (r RPCClient) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) { + return r.Client.GlossaryDeprecate(ctx, params) +} + func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.LocalizedStatus(ctx) } diff --git a/internal/api/server.go b/internal/api/server.go index 6ea54f0..c90ddb0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -76,6 +76,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff) mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary) mux.HandleFunc("/admin/translation/memory/query", s.handleAdminTranslationMemoryQuery) + mux.HandleFunc("/admin/translation/glossary/summary", s.handleAdminGlossarySummary) + mux.HandleFunc("/admin/translation/glossary/query", s.handleAdminGlossaryQuery) + mux.HandleFunc("/admin/translation/glossary/diagnose", s.handleAdminGlossaryDiagnose) mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus) mux.HandleFunc("/admin/control/", s.handleAdminControl) mux.HandleFunc("/admin/", s.handleAdminIndex) @@ -155,6 +158,9 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "/admin/translation/handoff", "/admin/translation/memory/summary", "/admin/translation/memory/query", + "/admin/translation/glossary/summary", + "/admin/translation/glossary/query", + "/admin/translation/glossary/diagnose", "/admin/translation/status", "/admin/control/{action}", }, diff --git a/internal/api/testdata/contract/README.md b/internal/api/testdata/contract/README.md index 15f8ebd..8d8fba0 100644 --- a/internal/api/testdata/contract/README.md +++ b/internal/api/testdata/contract/README.md @@ -10,6 +10,7 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本 - `resource.manifest` 第一页分页响应。 - 对应 release 的 `official-sync-snapshot.json`。 - `launcher_metadata` 与 `game_main_config_bootstrap` 的 Go mirror 解码。 +- Rust Glossary V1 query 响应,覆盖 alias、approved review、source provenance 和完整 history。 这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live daemon socket 或完整发布切换验证。 diff --git a/internal/api/testdata/contract/glossary-query.json b/internal/api/testdata/contract/glossary-query.json new file mode 100644 index 0000000..af1dc8f --- /dev/null +++ b/internal/api/testdata/contract/glossary-query.json @@ -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 + } + ] +} diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 8dc7562..1d79195 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -267,9 +267,10 @@ type ScheduleRunParams struct { // TranslationTaskUnitResultParam is the dashboard/manual-review subset of one // TextUnit result accepted by Rust translation.task.update. type TranslationTaskUnitResultParam struct { - UnitID string `json:"unit_id"` - SourceText string `json:"source_text"` - TranslatedText string `json:"translated_text"` + UnitID string `json:"unit_id"` + SourceText string `json:"source_text"` + TranslatedText string `json:"translated_text"` + GlossaryOverride *GlossaryOverride `json:"glossary_override,omitempty"` } // TranslationTaskUpdateParams is used by translation.task.update to persist @@ -310,6 +311,7 @@ type TranslationWorkerRunParams struct { Provider string `json:"provider,omitempty"` FixturePath string `json:"fixture_path,omitempty"` TranslationMemoryPath string `json:"translation_memory_path,omitempty"` + GlossaryPath string `json:"glossary_path,omitempty"` Concurrency *uint64 `json:"concurrency,omitempty"` MaxAttempts *uint64 `json:"max_attempts,omitempty"` LeaseSeconds *uint64 `json:"lease_seconds,omitempty"` @@ -323,6 +325,7 @@ type TranslationWorkerConfig struct { Provider string `json:"provider"` FixturePath string `json:"fixture_path,omitempty"` TranslationMemoryPath string `json:"translation_memory_path,omitempty"` + GlossaryPath string `json:"glossary_path,omitempty"` Concurrency uint64 `json:"concurrency"` MaxAttempts uint64 `json:"max_attempts"` LeaseSeconds uint64 `json:"lease_seconds"` @@ -476,6 +479,154 @@ type TranslationMemoryConfirmReport struct { Entry TranslationMemoryEntry `json:"entry"` } +// GlossaryReviewStatus is the Rust-owned term review state. +type GlossaryReviewStatus string + +const ( + GlossaryStatusDraft GlossaryReviewStatus = "draft" + GlossaryStatusApproved GlossaryReviewStatus = "approved" + GlossaryStatusDeprecated GlossaryReviewStatus = "deprecated" + GlossaryStatusRejected GlossaryReviewStatus = "rejected" +) + +// GlossarySourceRecord identifies the source/provenance of a term. +type GlossarySourceRecord struct { + SourceKind string `json:"source_kind"` + SourceRef *string `json:"source_ref,omitempty"` + SourceAuthor *string `json:"source_author,omitempty"` + SourceNote *string `json:"source_note,omitempty"` + ObservedUnixSeconds uint64 `json:"observed_unix_seconds"` +} + +// GlossaryTermSnapshot is the versioned definition shared by Rust and Go. +type GlossaryTermSnapshot struct { + SourceTerm string `json:"source_term"` + Aliases []string `json:"aliases,omitempty"` + RecommendedTranslation string `json:"recommended_translation"` + AllowedTranslations []string `json:"allowed_translations,omitempty"` + SourceLanguage *string `json:"source_language,omitempty"` + TargetLanguage *string `json:"target_language,omitempty"` + Category *string `json:"category,omitempty"` + Priority int `json:"priority"` + Scope map[string]string `json:"scope,omitempty"` +} + +// GlossaryOverride records explicit human approval for a deviation. +type GlossaryOverride struct { + Reviewer string `json:"reviewer"` + Reason string `json:"reason"` + Provenance string `json:"provenance"` + ConfirmedUnixSeconds uint64 `json:"confirmed_unix_seconds"` +} + +// GlossaryTerm mirrors a persisted Rust term and its source history. +type GlossaryTerm struct { + TermID string `json:"term_id"` + GlossaryTermSnapshot + ReviewStatus GlossaryReviewStatus `json:"review_status"` + Source GlossarySourceRecord `json:"source"` + History []GlossaryHistoryRecord `json:"history,omitempty"` + CreatedUnixSeconds uint64 `json:"created_unix_seconds"` + UpdatedUnixSeconds uint64 `json:"updated_unix_seconds"` +} + +// GlossaryHistoryRecord is one durable term mutation. +type GlossaryHistoryRecord struct { + HistoryID string `json:"history_id"` + Action string `json:"action"` + Reviewer *string `json:"reviewer,omitempty"` + Reason *string `json:"reason,omitempty"` + Source GlossarySourceRecord `json:"source"` + ReviewStatus GlossaryReviewStatus `json:"review_status"` + Snapshot GlossaryTermSnapshot `json:"snapshot"` + ObservedUnixSeconds uint64 `json:"observed_unix_seconds"` +} + +// GlossarySummary mirrors translation.glossary.summary. +type GlossarySummary struct { + SchemaVersion uint64 `json:"schema_version"` + TermCount uint64 `json:"term_count"` + ApprovedCount uint64 `json:"approved_count"` + DraftCount uint64 `json:"draft_count"` + DeprecatedCount uint64 `json:"deprecated_count"` + RejectedCount uint64 `json:"rejected_count"` +} + +type GlossarySummaryParams struct { + GlossaryPath string `json:"glossary_path,omitempty"` +} + +type GlossaryQueryParams struct { + GlossaryPath string `json:"glossary_path,omitempty"` + SourceText string `json:"source_text,omitempty"` + Category string `json:"category,omitempty"` + ReviewStatus string `json:"review_status,omitempty"` + Limit *uint64 `json:"limit,omitempty"` +} + +type GlossaryDiagnoseParams struct { + GlossaryPath string `json:"glossary_path,omitempty"` + SourceText string `json:"source_text"` + Context map[string]string `json:"context,omitempty"` +} + +type GlossaryTermMutationParams struct { + GlossaryPath string `json:"glossary_path,omitempty"` + TermID string `json:"term_id"` + SourceTerm string `json:"source_term"` + Aliases []string `json:"aliases,omitempty"` + RecommendedTranslation string `json:"recommended_translation"` + AllowedTranslations []string `json:"allowed_translations,omitempty"` + SourceLanguage *string `json:"source_language,omitempty"` + TargetLanguage *string `json:"target_language,omitempty"` + Category *string `json:"category,omitempty"` + Priority int `json:"priority"` + Scope map[string]string `json:"scope,omitempty"` + ReviewStatus string `json:"review_status"` + Source GlossarySourceRecord `json:"source"` + Reviewer string `json:"reviewer,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type GlossaryReviewParams struct { + GlossaryPath string `json:"glossary_path,omitempty"` + TermID string `json:"term_id"` + Reviewer string `json:"reviewer"` + Reason string `json:"reason,omitempty"` +} + +type 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 // publication. TranslationFile and FromWorker are mutually exclusive. type LocalizedPublishParams struct { @@ -751,6 +902,48 @@ func (c *Client) TranslationMemoryConfirm(ctx context.Context, params Translatio return &out, err } +func (c *Client) GlossarySummary(ctx context.Context, params GlossarySummaryParams) (*GlossarySummaryReport, error) { + var out GlossarySummaryReport + _, err := c.Call(ctx, "translation.glossary.summary", params, &out) + return &out, err +} + +func (c *Client) GlossaryQuery(ctx context.Context, params GlossaryQueryParams) (*GlossaryQueryReport, error) { + var out GlossaryQueryReport + _, err := c.Call(ctx, "translation.glossary.query", params, &out) + return &out, err +} + +func (c *Client) GlossaryDiagnose(ctx context.Context, params GlossaryDiagnoseParams) (*GlossaryDiagnoseReport, error) { + var out GlossaryDiagnoseReport + _, err := c.Call(ctx, "translation.glossary.diagnose", params, &out) + return &out, err +} + +func (c *Client) GlossaryAdd(ctx context.Context, params GlossaryTermMutationParams) (*GlossaryMutationReport, error) { + var out GlossaryMutationReport + _, err := c.Call(ctx, "translation.glossary.add", params, &out) + return &out, err +} + +func (c *Client) GlossaryUpdate(ctx context.Context, params GlossaryTermMutationParams) (*GlossaryMutationReport, error) { + var out GlossaryMutationReport + _, err := c.Call(ctx, "translation.glossary.update", params, &out) + return &out, err +} + +func (c *Client) GlossaryApprove(ctx context.Context, params GlossaryReviewParams) (*GlossaryMutationReport, error) { + var out GlossaryMutationReport + _, err := c.Call(ctx, "translation.glossary.approve", params, &out) + return &out, err +} + +func (c *Client) GlossaryDeprecate(ctx context.Context, params GlossaryReviewParams) (*GlossaryMutationReport, error) { + var out GlossaryMutationReport + _, err := c.Call(ctx, "translation.glossary.deprecate", params, &out) + return &out, err +} + func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) { return c.rawData(ctx, "localized.publish", params) } diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index cd00ede..4ca8f15 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -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) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "task.status" {