From 68c6c91b1e30b396e9d8ac03ebfd9e1d5d055633 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Wed, 9 Sep 2026 00:25:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(glossary):=20=E7=BB=91=E5=AE=9A=20QA=20iden?= =?UTF-8?q?tity=20=E4=B8=8E=E4=BA=BA=E5=B7=A5=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CURRENT_STATUS.md | 3 +- Cargo.lock | 1 + USERGUIDE.md | 9 +- api/openapi/bat-api.yaml | 7 +- core/Cargo.toml | 1 + core/src/domain/glossary.rs | 393 +++++++++++++++++- core/src/domain/mod.rs | 6 +- docs/guides/bat-workflows.md | 29 +- docs/reference/rpc-backend-api.md | 13 +- docs/reports/CURRENT_GAPS.md | 2 +- infrastructure/src/bin/bat/app.rs | 15 +- infrastructure/src/bin/bat/terminal_output.rs | 1 + .../src/bin/bat/translation_query.rs | 25 +- .../src/bin/bat/workflow_commands.rs | 14 +- infrastructure/src/lib.rs | 12 +- infrastructure/src/localized_patch.rs | 51 +++ infrastructure/src/translation_worker.rs | 5 + infrastructure/src/translation_workflow.rs | 335 +++++++++++++-- internal/api/api_test.go | 5 +- internal/api/openapi.go | 7 +- internal/backendrpc/client.go | 1 + internal/backendrpc/client_test.go | 1 + 22 files changed, 844 insertions(+), 92 deletions(-) diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index ab3e54a..0f191bb 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -42,7 +42,8 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成 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.*` 已通过 +稳定 `qa_identity` 以及 reviewer/reason/provenance 的显式 override,所有接受路径都会 +按当前 QA 精确校验 identity。`translation.glossary.*` 已通过 `bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。 --- diff --git a/Cargo.lock b/Cargo.lock index dcfacef..a69b024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,7 @@ version = "1.0.0" dependencies = [ "anyhow", "async-trait", + "blake3", "serde", "serde_json", "tempfile", diff --git a/USERGUIDE.md b/USERGUIDE.md index f734776..2bd6621 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -154,7 +154,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \ | `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/glossary/diagnose?source_text=...&context=...` | 执行 deterministic Glossary constraints/diagnostics QA 并返回 `qa_identity`;需要管理 token | | `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token | | `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 | @@ -186,7 +186,7 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn | `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report | | `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 | | `task-cancel` | `task.cancel` | `{ "task_id": "..." }` | `202` + 取消请求结果 | -| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "..." }] }` | `202` + 当前任务记录 | +| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "...", "glossary_override": { "qa_identity": "...", "reviewer": "...", "reason": "...", "provenance": "...", "confirmed_unix_seconds": 1 } }] }` | `202` + 当前任务记录 | | `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 记录 | @@ -346,8 +346,9 @@ bat i18n glossary delete --glossary-term-id term-sensei \ scope 为空表示全局;同一 TextUnit 内冲突会 blocked,priority、scope specificity、 匹配长度和 term ID 使用确定性排序。允许但非推荐译法只产生 warning,系统不会在译文 生成后自动替换文本。provider、TM、人工 task update、workbench 和 localized publish -均执行相同 QA;blocking deviation 需要 `reviewer`、`reason`、`provenance` 和确认时间 -组成显式 override。 +均执行相同 QA;每个具体 QA 都有稳定的 `qa_identity`。blocking deviation 需要 +`qa_identity`、`reviewer`、`reason`、`provenance` 和确认时间组成显式 override; +Glossary 相关定义变化会使受影响 override 失效,无关术语变化不会使其失效。 --- diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index 496eeda..2de744d 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -527,7 +527,7 @@ paths: type: string responses: "200": - description: Glossary constraints, diagnostics, and blocked decision. + description: Glossary constraints, diagnostics, blocked decision, and stable qa_identity. "400": description: Missing source text or invalid context. "401": @@ -621,9 +621,12 @@ paths: type: string glossary_override: type: object - required: [reviewer, reason, provenance, confirmed_unix_seconds] + required: [qa_identity, reviewer, reason, provenance, confirmed_unix_seconds] additionalProperties: false properties: + qa_identity: + type: string + minLength: 1 reviewer: type: string reason: diff --git a/core/Cargo.toml b/core/Cargo.toml index 2846b05..1ad74a3 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -12,6 +12,7 @@ serde.workspace = true serde_json.workspace = true async-trait.workspace = true tokio.workspace = true +blake3.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/core/src/domain/glossary.rs b/core/src/domain/glossary.rs index 3552aca..904b55d 100644 --- a/core/src/domain/glossary.rs +++ b/core/src/domain/glossary.rs @@ -268,6 +268,9 @@ pub struct GlossaryDiagnostic { /// Result of applying approved terms to one TextUnit source. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct GlossaryEvaluation { + /// Stable identity of the glossary facts used to produce this evaluation. + #[serde(default)] + pub qa_identity: String, /// Approved constraints sent to a provider. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub constraints: Vec, @@ -326,20 +329,30 @@ impl GlossaryEvaluation { }); } } - GlossaryQaReport { - status: if blocked { - GlossaryQaStatus::Blocked - } else if diagnostics - .iter() - .any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::NonRecommended) - { - GlossaryQaStatus::Warning - } else { - GlossaryQaStatus::Pass - }, + let status = if blocked { + GlossaryQaStatus::Blocked + } else if diagnostics + .iter() + .any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::NonRecommended) + { + GlossaryQaStatus::Warning + } else { + GlossaryQaStatus::Pass + }; + let mut report = GlossaryQaReport { + qa_identity: String::new(), + status, constraints: self.constraints.clone(), diagnostics, - } + }; + report.qa_identity = output_qa_identity( + &self.qa_identity, + translated_text, + &report.status, + &report.constraints, + &report.diagnostics, + ); + report } } @@ -374,6 +387,9 @@ impl GlossaryQaStatus { /// Persisted glossary QA attached to a translation result. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct GlossaryQaReport { + /// Stable identity of the complete blocking-QA result. + #[serde(default)] + pub qa_identity: String, /// QA status. pub status: GlossaryQaStatus, /// Constraints evaluated. @@ -387,6 +403,9 @@ pub struct GlossaryQaReport { /// Explicit human approval to deviate from a blocking glossary result. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct GlossaryOverride { + /// Exact blocking-QA identity reviewed by the human. + #[serde(default)] + pub qa_identity: String, /// Reviewer identity. pub reviewer: String, /// Required reason. @@ -407,6 +426,7 @@ pub fn evaluate_glossary( struct Candidate { term_id: String, matched_source: String, + definition: GlossaryTermSnapshot, recommendation: String, allowed: Vec, category: Option, @@ -436,6 +456,7 @@ pub fn evaluate_glossary( candidates.push(Candidate { term_id: term.term_id.clone(), matched_source: spelling.clone(), + definition: term.definition.clone(), recommendation: term.definition.recommended_translation.clone(), allowed: term.definition.allowed_translations.clone(), category: term.definition.category.clone(), @@ -458,6 +479,18 @@ pub fn evaluate_glossary( .then_with(|| left.term_id.cmp(&right.term_id)) .then_with(|| left.matched_source.cmp(&right.matched_source)) }); + let identity_candidates = candidates + .iter() + .map(|candidate| GlossaryIdentityCandidate { + term_id: candidate.term_id.clone(), + matched_source: candidate.matched_source.clone(), + definition: candidate.definition.clone(), + priority: candidate.priority, + start: candidate.start, + end: candidate.end, + specificity: candidate.specificity, + }) + .collect::>(); let mut selected = Vec::new(); let mut diagnostics = Vec::new(); @@ -505,7 +538,7 @@ pub fn evaluate_glossary( .then_with(|| left.matched_source.cmp(&right.matched_source)) }); let mut seen = BTreeSet::new(); - let constraints = selected + let constraints: Vec = selected .into_iter() .filter(|candidate| { seen.insert(( @@ -524,13 +557,242 @@ pub fn evaluate_glossary( scope: candidate.scope, }) .collect(); + let qa_identity = evaluation_identity( + source_text, + &identity_candidates, + &constraints, + &diagnostics, + ); GlossaryEvaluation { + qa_identity, constraints, diagnostics, blocked, } } +/// Validates that an explicit human confirmation authorizes the current +/// blocking glossary result, rather than merely looking structurally complete. +pub fn validate_glossary_override( + qa: &GlossaryQaReport, + glossary_override: Option<&GlossaryOverride>, +) -> crate::Result<()> { + if !qa.status.is_blocked() { + return Err(crate::Error::InvalidArgument( + "Glossary override 只能用于 blocking QA".to_string(), + )); + } + if qa.qa_identity.trim().is_empty() { + return Err(crate::Error::InvalidArgument( + "当前 Glossary QA 缺少 qa_identity,不能接受 override".to_string(), + )); + } + let Some(glossary_override) = glossary_override else { + return Err(crate::Error::InvalidArgument( + "Glossary QA blocked;需要 reviewer、reason、provenance 和 qa_identity 显式确认" + .to_string(), + )); + }; + 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(crate::Error::InvalidArgument( + "Glossary override 的 reviewer、reason、provenance 和 confirmed_unix_seconds 必须有效" + .to_string(), + )); + } + if glossary_override.qa_identity.trim().is_empty() { + return Err(crate::Error::InvalidArgument( + "Glossary override 缺少 qa_identity;旧 override 不能自动复用".to_string(), + )); + } + if glossary_override.qa_identity != qa.qa_identity { + return Err(crate::Error::InvalidArgument(format!( + "Glossary override 的 qa_identity={} 与当前 QA={} 不一致", + glossary_override.qa_identity, qa.qa_identity + ))); + } + Ok(()) +} + +const GLOSSARY_QA_IDENTITY_VERSION: &[u8] = b"bat-glossary-qa-v1"; + +fn evaluation_identity( + source_text: &str, + candidates: &[GlossaryIdentityCandidate], + constraints: &[GlossaryConstraint], + diagnostics: &[GlossaryDiagnostic], +) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(GLOSSARY_QA_IDENTITY_VERSION); + hash_string(&mut hasher, source_text); + let mut candidates = candidates.iter().collect::>(); + candidates.sort_by_key(|candidate| candidate.identity_sort_key()); + hash_len(&mut hasher, candidates.len()); + for candidate in candidates { + candidate.hash_identity(&mut hasher); + } + hash_constraints(&mut hasher, constraints); + hash_diagnostics(&mut hasher, diagnostics); + format!("gqa-v1-{}", hasher.finalize().to_hex()) +} + +fn output_qa_identity( + evaluation_identity: &str, + translated_text: &str, + status: &GlossaryQaStatus, + constraints: &[GlossaryConstraint], + diagnostics: &[GlossaryDiagnostic], +) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(GLOSSARY_QA_IDENTITY_VERSION); + hash_string(&mut hasher, evaluation_identity); + hash_string(&mut hasher, translated_text); + hash_string(&mut hasher, status.as_str()); + hash_constraints(&mut hasher, constraints); + hash_diagnostics(&mut hasher, diagnostics); + format!("gqa-v1-{}", hasher.finalize().to_hex()) +} + +struct GlossaryIdentityCandidate { + term_id: String, + matched_source: String, + definition: GlossaryTermSnapshot, + priority: i32, + start: usize, + end: usize, + specificity: usize, +} + +impl GlossaryIdentityCandidate { + fn identity_sort_key(&self) -> (String, String, usize, usize, i32, usize) { + ( + self.term_id.clone(), + self.matched_source.clone(), + self.start, + self.end, + self.priority, + self.specificity, + ) + } + + fn hash_identity(&self, hasher: &mut blake3::Hasher) { + hash_string(hasher, &self.term_id); + hash_string(hasher, &self.matched_source); + hash_usize(hasher, self.start); + hash_usize(hasher, self.end); + hash_i32(hasher, self.priority); + hash_usize(hasher, self.specificity); + hash_snapshot(hasher, &self.definition); + } +} + +fn hash_len(hasher: &mut blake3::Hasher, value: usize) { + hasher.update(&(value as u64).to_le_bytes()); +} + +fn hash_usize(hasher: &mut blake3::Hasher, value: usize) { + hasher.update(&(value as u64).to_le_bytes()); +} + +fn hash_i32(hasher: &mut blake3::Hasher, value: i32) { + hasher.update(&value.to_le_bytes()); +} + +fn hash_bool(hasher: &mut blake3::Hasher, value: bool) { + hasher.update(&[value as u8]); +} + +fn hash_string(hasher: &mut blake3::Hasher, value: &str) { + hash_len(hasher, value.len()); + hasher.update(value.as_bytes()); +} + +fn hash_option_string(hasher: &mut blake3::Hasher, value: Option<&str>) { + match value { + Some(value) => { + hash_bool(hasher, true); + hash_string(hasher, value); + } + None => hash_bool(hasher, false), + } +} + +fn hash_string_list(hasher: &mut blake3::Hasher, values: &[String]) { + let mut values = values.to_vec(); + values.sort(); + values.dedup(); + hash_len(hasher, values.len()); + for value in values { + hash_string(hasher, &value); + } +} + +fn hash_map(hasher: &mut blake3::Hasher, values: &BTreeMap) { + hash_len(hasher, values.len()); + for (key, value) in values { + hash_string(hasher, key); + hash_string(hasher, value); + } +} + +fn hash_snapshot(hasher: &mut blake3::Hasher, snapshot: &GlossaryTermSnapshot) { + hash_string(hasher, &snapshot.source_term); + hash_string_list(hasher, &snapshot.aliases); + hash_string(hasher, &snapshot.recommended_translation); + hash_string_list(hasher, &snapshot.allowed_translations); + hash_option_string(hasher, snapshot.source_language.as_deref()); + hash_option_string(hasher, snapshot.target_language.as_deref()); + hash_option_string(hasher, snapshot.category.as_deref()); + hash_i32(hasher, snapshot.priority); + hash_map(hasher, &snapshot.scope); +} + +fn hash_constraints(hasher: &mut blake3::Hasher, constraints: &[GlossaryConstraint]) { + let mut constraints = constraints.to_vec(); + constraints.sort_by(|left, right| { + left.term_id + .cmp(&right.term_id) + .then_with(|| left.matched_source.cmp(&right.matched_source)) + .then_with(|| left.priority.cmp(&right.priority)) + .then_with(|| { + left.recommended_translation + .cmp(&right.recommended_translation) + }) + }); + hash_len(hasher, constraints.len()); + for constraint in constraints { + hash_string(hasher, &constraint.term_id); + hash_string(hasher, &constraint.matched_source); + hash_string(hasher, &constraint.recommended_translation); + hash_string_list(hasher, &constraint.allowed_translations); + hash_option_string(hasher, constraint.category.as_deref()); + hash_i32(hasher, constraint.priority); + hash_map(hasher, &constraint.scope); + } +} + +fn hash_diagnostics(hasher: &mut blake3::Hasher, diagnostics: &[GlossaryDiagnostic]) { + let mut diagnostics = diagnostics.to_vec(); + diagnostics.sort_by(|left, right| { + left.kind + .as_str() + .cmp(right.kind.as_str()) + .then_with(|| left.term_id.cmp(&right.term_id)) + .then_with(|| left.value.cmp(&right.value)) + .then_with(|| left.message.cmp(&right.message)) + }); + hash_len(hasher, diagnostics.len()); + for diagnostic in diagnostics { + hash_string(hasher, diagnostic.kind.as_str()); + hash_option_string(hasher, diagnostic.term_id.as_deref()); + hash_option_string(hasher, diagnostic.value.as_deref()); + hash_string(hasher, &diagnostic.message); + } +} + /// 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() @@ -681,6 +943,111 @@ mod tests { .diagnostics .iter() .any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Violation)); + assert!(!blocked.qa_identity.is_empty()); + } + + #[test] + fn qa_identity_is_stable_for_input_order_and_unrelated_terms() { + let mut first = term("first", "Sensei", "老师", 1, BTreeMap::new()); + first.definition.aliases = vec!["Teacher".to_string(), "Sensei".to_string()]; + let mut second = first.clone(); + second.definition.aliases.reverse(); + let unrelated = term("unrelated", "Other", "其他", 1, BTreeMap::new()); + let source = "Teacher"; + let context = BTreeMap::new(); + + let first_qa = evaluate_glossary(&[first.clone(), unrelated.clone()], source, &context) + .check_translation("先生"); + let reordered_qa = + evaluate_glossary(&[unrelated, second], source, &context).check_translation("先生"); + assert_eq!(first_qa.qa_identity, reordered_qa.qa_identity); + + let mut changed_unrelated = term("unrelated", "Other", "别的译法", 1, BTreeMap::new()); + changed_unrelated.definition.allowed_translations = vec!["其他".to_string()]; + let unrelated_changed_qa = evaluate_glossary(&[first, changed_unrelated], source, &context) + .check_translation("先生"); + assert_eq!(first_qa.qa_identity, unrelated_changed_qa.qa_identity); + } + + #[test] + fn qa_identity_changes_when_relevant_glossary_facts_change() { + let base = term("sensei", "Sensei", "老师", 1, BTreeMap::new()); + let base_qa = evaluate_glossary(std::slice::from_ref(&base), "Sensei", &BTreeMap::new()) + .check_translation("先生"); + + let mut recommended = base.clone(); + recommended.definition.recommended_translation = "教师".to_string(); + let recommended_qa = + evaluate_glossary(&[recommended], "Sensei", &BTreeMap::new()).check_translation("先生"); + assert_ne!(base_qa.qa_identity, recommended_qa.qa_identity); + + let mut allowed = base.clone(); + allowed.definition.allowed_translations = vec!["先生".to_string()]; + let allowed_qa = + evaluate_glossary(&[allowed], "Sensei", &BTreeMap::new()).check_translation("先生"); + assert_ne!(base_qa.qa_identity, allowed_qa.qa_identity); + + let mut scoped = base.clone(); + scoped + .definition + .scope + .insert("destination".to_string(), "story".to_string()); + let scoped_qa = + evaluate_glossary(&[scoped], "Sensei", &BTreeMap::new()).check_translation("先生"); + assert_ne!(base_qa.qa_identity, scoped_qa.qa_identity); + + let mut aliased = base.clone(); + aliased.definition.source_term = "Instructor".to_string(); + aliased.definition.aliases = vec!["Sensei".to_string()]; + let aliased_qa = + evaluate_glossary(&[aliased], "Sensei", &BTreeMap::new()).check_translation("先生"); + assert_ne!(base_qa.qa_identity, aliased_qa.qa_identity); + + let conflict = vec![ + term("a", "Sensei", "老师", 1, BTreeMap::new()), + term("b", "Sensei", "导师", 1, BTreeMap::new()), + ]; + let conflict_qa = + evaluate_glossary(&conflict, "Sensei", &BTreeMap::new()).check_translation("先生"); + let mut priority_changed = conflict; + priority_changed[1].definition.priority = 2; + let priority_qa = evaluate_glossary(&priority_changed, "Sensei", &BTreeMap::new()) + .check_translation("先生"); + assert_ne!(conflict_qa.qa_identity, priority_qa.qa_identity); + } + + #[test] + fn override_must_match_current_blocking_qa_identity() { + let evaluation = evaluate_glossary( + &[term("sensei", "Sensei", "老师", 1, BTreeMap::new())], + "Sensei", + &BTreeMap::new(), + ); + let qa = evaluation.check_translation("先生"); + let mut override_record = GlossaryOverride { + qa_identity: qa.qa_identity.clone(), + reviewer: "reviewer".to_string(), + reason: "manual review".to_string(), + provenance: "workbench".to_string(), + confirmed_unix_seconds: 1, + }; + assert!(validate_glossary_override(&qa, Some(&override_record)).is_ok()); + + override_record.qa_identity.clear(); + assert!(validate_glossary_override(&qa, Some(&override_record)).is_err()); + override_record.qa_identity = "gqa-v1-old".to_string(); + assert!(validate_glossary_override(&qa, Some(&override_record)).is_err()); + + let old_override: GlossaryOverride = serde_json::from_str( + r#"{ + "reviewer": "reviewer", + "reason": "manual review", + "provenance": "workbench", + "confirmed_unix_seconds": 1 + }"#, + ) + .unwrap(); + assert!(validate_glossary_override(&qa, Some(&old_override)).is_err()); } #[test] diff --git a/core/src/domain/mod.rs b/core/src/domain/mod.rs index c3c8396..5a42aab 100644 --- a/core/src/domain/mod.rs +++ b/core/src/domain/mod.rs @@ -10,9 +10,9 @@ 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, + evaluate_glossary, validate_glossary_draft, validate_glossary_override, GlossaryConstraint, + GlossaryDiagnostic, GlossaryDiagnosticKind, GlossaryEvaluation, GlossaryHistoryRecord, + GlossaryOverride, GlossaryQaReport, GlossaryQaStatus, GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossarySummary, GlossaryTerm, GlossaryTermDraft, GlossaryTermSnapshot, }; pub use resource::{ diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index 406ba08..9322c8d 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -89,7 +89,23 @@ bat i18n set \ --translated-text '中文文本' ``` -也可以使用 `--translated-file` 读取 UTF-8 文本。需要复核单条内容时: +也可以使用 `--translated-file` 读取 UTF-8 文本。 +如果译文触发 blocking Glossary QA,需先从 diagnose/任务结果取得当前 +`qa_identity`,并与 reviewer、reason、provenance 一起提交;系统不会根据 workbench +中旧的 QA 自动补填: + +```bash +bat i18n set \ + --translation-file /tmp/bat-workbench.json \ + --translation-id \ + --translated-text '人工确认的译文' \ + --glossary-qa-identity \ + --glossary-reviewer operator \ + --glossary-reason '人工确认术语偏离' \ + --glossary-provenance workbench +``` + +需要复核单条内容时: ```bash bat i18n get \ @@ -166,7 +182,8 @@ Glossary V1 是 Rust `bat` 持有的独立项目级 SQLite 资产,默认位于 `BAT_GLOSSARY_PATH` 或 `[translation.worker].glossary_path` 指定。worker 只把 `approved` term 转成 provider-neutral constraints,并在 TM 复用、provider 返回 和人工工作台/任务回写时执行相同的确定性 QA。冲突或不符合推荐/允许译法的结果会 -阻止自动完成;必须提交带 reviewer、reason 和 provenance 的显式 override。 +阻止自动完成;必须提交带当前 `qa_identity`、reviewer、reason 和 provenance 的显式 +override。 常用 Glossary 操作: @@ -303,7 +320,9 @@ Rust `translation.tasks` / `translation.handoff`,不在 Go 侧维护状态。 `translation.task.update`。人工校对流程提交译文时必须使用 `status=completed`, 并为每个 `translation_results[]` 提供 `unit_id`、`source_text` 和 `translated_text`,Rust 会用当前 `official-textunit-index.json` 校验 unit、 -source text、destination 和 archive entry 后再落库。 +source text、destination 和 archive entry 后再落库。blocking Glossary QA 还必须提交 +与当前 QA 完全相等的 `glossary_override.qa_identity`;旧或缺少 identity 的 override +不会授权。 `POST /admin/control/translation-worker-run` 会触发 Rust 侧 `translation.worker.run`,请求字段为 `provider`、`fixture_path`、 @@ -320,8 +339,8 @@ source text、destination 和 archive entry 后再落库。 release,校验工作台与当前 TextUnit 索引的 source/location 一致后,写入已有支持 范围内的 TextAsset、TypeTree string field 和 managed-reference string field patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录 -源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review 和 rollback -信息。ZIP 内 bundle 不会被静默改写。 +源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review、发布时重新计算的 +Glossary QA/override 和 rollback 信息。ZIP 内 bundle 不会被静默改写。 使用人工编辑的工作台发布: diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index a954559..ec83c41 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -287,8 +287,10 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化 `status=completed` 时额外提交 `provider`、`provider_run_id` 和 `translation_results[]`,每个结果必须包含 `unit_id`、`source_text` 和 `translated_text`;结果也可以提交完整的 `glossary_override`(`reviewer`、 -`reason`、`provenance`、`confirmed_unix_seconds`),用于人工确认 Glossary -blocking deviation。Rust 会用当前 `official-textunit-index.json` 校验 unit、 +`reason`、`provenance`、`confirmed_unix_seconds` 和当前 blocking QA 的 +`qa_identity`),用于人工确认 Glossary blocking deviation。`qa_identity` 必须与 +Rust 重新计算的当前 QA 完全相等;缺失或过期的 override 不授权。Rust 会用当前 +`official-textunit-index.json` 校验 unit、 source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程 消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过 `translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff` @@ -326,7 +328,9 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t 复用前、provider 返回后、人工 `translation.task.update` 和 workbench publish 前执行 同一套确定性 QA;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法 产生 warning;blocking deviation 必须在对应结果中提交 `glossary_override`,并包含 -`reviewer`、`reason`、`provenance` 和确认时间。系统不会在译文生成后做静默字符串替换。 +`qa_identity`、`reviewer`、`reason`、`provenance` 和确认时间。Glossary 定义变化会 +只使受影响 QA 的旧 override 失效;无关术语变化不会改变该 QA identity。系统不会在 +译文生成后做静默字符串替换。 ### localized @@ -351,6 +355,9 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t `patch_manifest_path`、`patch_manifest_available`、 `patch_manifest_matches_release`、`patch_file_count`、 `patch_text_asset_operation_count` 和 `rollback_previous_current_target`。 +每个 localized patch operation 的 manifest metadata 记录发布时重新计算的 +`glossary_qa`(包括 `qa_identity`)及对应 `glossary_override`,不会复用 workbench +中已经过期的 QA 快照。 ### catalog diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 9530572..fe81842 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -117,7 +117,7 @@ Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整 ### 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 术语协作视图和更丰富的导入/搜索能力。 +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 必须显式提交与当前 QA 精确绑定的 `qa_identity` 及 reviewer/reason/provenance。localized publish 会把发布时重算的 QA 写入 manifest。`translation.glossary.*` 已通过 `bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图和更丰富的导入/搜索能力。 ### G-014:完整 Provider 扩展体系未实现 diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index 32bffda..97770f7 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -10,10 +10,10 @@ use bat_infrastructure::{ apply_unityfs_text_asset_patch_file, changed_endpoint_urls, completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench, gc_orphan_staging_with_cas_root, get_translation_entry, lexical_absolute, - 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, + localized_patch_operations_with_glossary_path, 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, 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, @@ -430,6 +430,7 @@ struct CliOptions { glossary_reviewer: Option, glossary_reason: Option, glossary_override_provenance: Option, + glossary_qa_identity: Option, glossary_source_text: Option, glossary_context_json: Option, glossary_review_status: Option, @@ -555,6 +556,7 @@ impl Default for CliOptions { glossary_reviewer: None, glossary_reason: None, glossary_override_provenance: None, + glossary_qa_identity: None, glossary_source_text: None, glossary_context_json: None, glossary_review_status: None, @@ -6949,6 +6951,9 @@ fn parse_args_with_env( "--glossary-provenance" | "--glossary-override-provenance" => { options.glossary_override_provenance = Some(next_option_value(&mut args, &flag)?); } + "--glossary-qa-identity" => { + options.glossary_qa_identity = Some(next_option_value(&mut args, &flag)?); + } "--glossary-source-text" => { options.glossary_source_text = Some(next_option_value(&mut args, &flag)?); } @@ -7871,7 +7876,7 @@ fn parse_args_with_env( || tools_are_non_default(&options.config, &options.env_baseline_config) { return Err(anyhow::anyhow!( - "translation workbench 编辑命令只接受 --translation-file、--translation-id、译文字段、--state-dir 和 --json/--human" + "translation workbench 编辑命令只接受 --translation-file、--translation-id、译文和 Glossary override 字段、--state-dir 以及 --json/--human" )); } options.progress = false; diff --git a/infrastructure/src/bin/bat/terminal_output.rs b/infrastructure/src/bin/bat/terminal_output.rs index b94d97e..98a06ae 100644 --- a/infrastructure/src/bin/bat/terminal_output.rs +++ b/infrastructure/src/bin/bat/terminal_output.rs @@ -448,6 +448,7 @@ Sync: --glossary-reviewer Reviewer for Glossary updates/reviews/delete --glossary-reason Reason for Glossary review/delete or override --glossary-provenance Provenance for an explicit Glossary override + --glossary-qa-identity Current blocking Glossary QA identity for an 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 a58fee6..2147e59 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::{GlossaryOverride, TranslationMemoryContext}; +use bat_core::domain::{validate_glossary_override, GlossaryOverride, TranslationMemoryContext}; use bat_core::repositories::TranslationMemoryRepository; use std::collections::BTreeMap; @@ -377,24 +377,19 @@ async fn build_manual_translation_results( } 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 { + if let Some(qa) = glossary_qa.as_ref() { + if qa.status.is_blocked() { + validate_glossary_override(qa, param.glossary_override.as_ref()).map_err( + |error| { + anyhow::anyhow!("TextUnit {} 的 glossary_override 无效:{error}", unit_id) + }, + )?; + } else if param.glossary_override.is_some() { 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 无效", + "TextUnit {} 不能为非 blocking Glossary QA 指定 override", unit_id )); } - let _ = qa; } else if param.glossary_override.is_some() { return Err(anyhow::anyhow!( "TextUnit {} 不能为非 blocking Glossary QA 指定 override", diff --git a/infrastructure/src/bin/bat/workflow_commands.rs b/infrastructure/src/bin/bat/workflow_commands.rs index 5d84f4f..5bab9d8 100644 --- a/infrastructure/src/bin/bat/workflow_commands.rs +++ b/infrastructure/src/bin/bat/workflow_commands.rs @@ -150,10 +150,12 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> { options.glossary_reviewer.as_deref(), options.glossary_reason.as_deref(), options.glossary_override_provenance.as_deref(), + options.glossary_qa_identity.as_deref(), ) { - (None, None, None) => None, - (Some(reviewer), Some(reason), Some(provenance)) => Some( + (None, None, None, None) => None, + (Some(reviewer), Some(reason), Some(provenance), Some(qa_identity)) => Some( bat_core::domain::GlossaryOverride { + qa_identity: qa_identity.to_string(), reviewer: reviewer.to_string(), reason: reason.to_string(), provenance: provenance.to_string(), @@ -162,7 +164,7 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> { ), _ => { return Err(anyhow::anyhow!( - "Glossary override 必须同时指定 --glossary-reviewer、--glossary-reason 和 --glossary-provenance" + "Glossary override 必须同时指定 --glossary-qa-identity、--glossary-reviewer、--glossary-reason 和 --glossary-provenance" )) } }; @@ -371,7 +373,11 @@ pub(super) fn publish_localized_report( &workbench, options.glossary_path.as_deref(), )?; - let operations = localized_patch_operations(&resource_root, &workbench)?; + let operations = localized_patch_operations_with_glossary_path( + &resource_root, + &workbench, + options.glossary_path.as_deref(), + )?; let localized_release_id = options.localized_release_id.clone().or_else(|| { options .config diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 343eb34..069deae 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -173,12 +173,12 @@ pub use translation_worker::{ 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, - 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, + localized_patch_operations_with_glossary_path, localized_text_asset_patches, + read_translation_workbench, repack_bundle, set_translation, 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 cdd5204..750a68d 100644 --- a/infrastructure/src/localized_patch.rs +++ b/infrastructure/src/localized_patch.rs @@ -352,6 +352,12 @@ pub struct LocalizedPatchOperation { /// Review state used by the publication input. #[serde(default, skip_serializing_if = "Option::is_none")] pub review_status: Option, + /// Glossary QA recomputed for this publication. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_qa: Option, + /// Human confirmation bound to the published Glossary QA identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub glossary_override: Option, } /// Rollback information recorded for a localized publication. @@ -962,6 +968,8 @@ impl LocalizedPatchOperation { translation_source_kind: None, translation_memory_record_id: None, review_status: None, + glossary_qa: None, + glossary_override: None, }, metadata, ) @@ -989,6 +997,8 @@ impl LocalizedPatchOperation { translation_source_kind: None, translation_memory_record_id: None, review_status: None, + glossary_qa: None, + glossary_override: None, }, metadata, ) @@ -1015,6 +1025,8 @@ impl LocalizedPatchOperation { translation_source_kind: None, translation_memory_record_id: None, review_status: None, + glossary_qa: None, + glossary_override: None, }, metadata, )) @@ -1032,6 +1044,8 @@ impl LocalizedPatchOperation { operation.translation_source_kind = metadata.translation_source_kind.clone(); operation.translation_memory_record_id = metadata.translation_memory_record_id.clone(); operation.review_status = Some(metadata.review_status.clone()); + operation.glossary_qa = metadata.glossary_qa.clone(); + operation.glossary_override = metadata.glossary_override.clone(); } operation } @@ -1058,6 +1072,8 @@ impl Default for LocalizedPatchOperation { translation_source_kind: None, translation_memory_record_id: None, review_status: None, + glossary_qa: None, + glossary_override: None, } } } @@ -1432,6 +1448,8 @@ mod tests { translation_source_kind: Some("provider".to_string()), translation_memory_record_id: None, review_status: Some("provider_completed".to_string()), + glossary_qa: None, + glossary_override: None, }], }], rollback: LocalizedPatchRollbackInfo { @@ -1461,6 +1479,39 @@ mod tests { ); } + #[test] + fn manifest_operation_keeps_current_glossary_qa_provenance() { + let qa = bat_core::domain::GlossaryQaReport { + qa_identity: "gqa-v1-current".to_string(), + status: bat_core::domain::GlossaryQaStatus::Pass, + constraints: Vec::new(), + diagnostics: Vec::new(), + }; + let input = LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch { + bundle_path: "Bundles/file.bundle".to_string(), + text_asset: TextAssetPatch::new("CAB-asset", 1, b"target".to_vec()), + metadata: Some(LocalizedPatchOperationMetadata { + text_unit_id: "unit-1".to_string(), + source_text_blake3: "source-hash".to_string(), + translation_provider: None, + provider_run_id: None, + translation_source_kind: Some("manual".to_string()), + translation_memory_record_id: None, + review_status: "manual_reviewed".to_string(), + glossary_qa: Some(qa.clone()), + glossary_override: None, + }), + }); + let operation = input.manifest_operation().unwrap(); + assert_eq!( + operation + .glossary_qa + .as_ref() + .map(|report| report.qa_identity.as_str()), + Some("gqa-v1-current") + ); + } + #[cfg(unix)] #[test] fn publishes_a_separate_localized_release_atomically() { diff --git a/infrastructure/src/translation_worker.rs b/infrastructure/src/translation_worker.rs index 17f37b4..8fb8a7a 100644 --- a/infrastructure/src/translation_worker.rs +++ b/infrastructure/src/translation_worker.rs @@ -968,6 +968,7 @@ async fn process_claimed_task( } } else { bat_core::domain::GlossaryEvaluation { + qa_identity: String::new(), constraints: Vec::new(), diagnostics: Vec::new(), blocked: false, @@ -1760,6 +1761,10 @@ mod tests { .map(|qa| qa.status), Some(bat_core::domain::GlossaryQaStatus::Pass) ); + assert!(task.translation_results[0] + .glossary_qa + .as_ref() + .is_some_and(|qa| !qa.qa_identity.is_empty())); } #[tokio::test] diff --git a/infrastructure/src/translation_workflow.rs b/infrastructure/src/translation_workflow.rs index 70e2228..676521c 100644 --- a/infrastructure/src/translation_workflow.rs +++ b/infrastructure/src/translation_workflow.rs @@ -16,7 +16,10 @@ 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 bat_core::domain::{ + validate_glossary_override as validate_core_glossary_override, GlossaryOverride, + GlossaryQaReport, +}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; @@ -333,9 +336,8 @@ pub fn set_translation_checked_with_glossary_path( .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; + if let Some(qa) = qa.as_ref() { + validate_current_glossary_qa(qa, glossary_override.as_ref())?; } else if glossary_override.is_some() { return Err(anyhow::anyhow!( "Glossary override 只能用于存在 blocking QA 的译文" @@ -464,9 +466,12 @@ pub fn validate_translation_workbench_with_glossary_path( } 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())?; - } + validate_current_glossary_qa(&qa, entry.glossary_override.as_ref())?; + } else if entry.glossary_override.is_some() { + return Err(anyhow::anyhow!( + "TextUnit {} 存在 Glossary override,但当前没有 Glossary 数据库", + entry.id + )); } changed_entries += 1; let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref()); @@ -560,20 +565,15 @@ fn evaluate_glossary_entry( .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 必须有效" - )); +fn validate_current_glossary_qa( + qa: &GlossaryQaReport, + glossary_override: Option<&GlossaryOverride>, +) -> anyhow::Result<()> { + if qa.status.is_blocked() { + validate_core_glossary_override(qa, glossary_override) + .map_err(|error| anyhow::anyhow!("{error}"))?; + } else if glossary_override.is_some() { + return Err(anyhow::anyhow!("Glossary override 只能用于 blocking QA")); } Ok(()) } @@ -587,6 +587,23 @@ pub fn localized_patch_operations( resource_root: &Path, workbench: &TranslationWorkbench, ) -> anyhow::Result> { + localized_patch_operations_with_glossary_path(resource_root, workbench, None) +} + +/// Converts reviewed entries to patch operations using QA recomputed from the +/// current Glossary. The recomputed report is the one persisted to the +/// localized release manifest. +pub fn localized_patch_operations_with_glossary_path( + resource_root: &Path, + workbench: &TranslationWorkbench, + configured_glossary_path: Option<&Path>, +) -> anyhow::Result> { + validate_translation_workbench_with_glossary_path( + resource_root, + &workbench.official_release_id, + workbench, + configured_glossary_path, + )?; let index = read_textunit_index_at(resource_root) .map_err(anyhow::Error::msg)? .ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?; @@ -595,6 +612,10 @@ pub fn localized_patch_operations( .iter() .map(|unit| (unit.id.as_str(), unit)) .collect::>(); + 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 mut seen = BTreeSet::new(); let mut operations = Vec::new(); @@ -609,6 +630,18 @@ pub fn localized_patch_operations( if translated_text == &entry.source_text { continue; } + let current_qa = glossary + .as_ref() + .map(|glossary| evaluate_glossary_entry(glossary, current, translated_text)) + .transpose()?; + if let Some(qa) = current_qa.as_ref() { + validate_current_glossary_qa(qa, entry.glossary_override.as_ref())?; + } else if entry.glossary_override.is_some() { + return Err(anyhow::anyhow!( + "TextUnit {} 存在 Glossary override,但当前没有 Glossary 数据库", + entry.id + )); + } let Some(serialized_file) = entry.serialized_file.clone() else { return Err(anyhow::anyhow!( "TextUnit {} 没有 serialized_file,当前不能生成重打包 patch", @@ -640,7 +673,11 @@ pub fn localized_patch_operations( entry.id )); } - let metadata = Some(localized_patch_metadata(entry)); + let metadata = Some(localized_patch_metadata( + entry, + current_qa.as_ref(), + entry.glossary_override.as_ref(), + )); match source_kind.as_deref() { Some("textasset") => { let mut patch = TextAssetPatch::new( @@ -706,6 +743,8 @@ pub fn localized_text_asset_patches( .iter() .map(|unit| (unit.id.as_str(), unit)) .collect::>(); + let glossary = + open_glossary_if_present(&SqliteGlossaryRepository::repository_path(resource_root))?; let mut seen = BTreeSet::new(); let mut patches = Vec::new(); @@ -758,10 +797,26 @@ pub fn localized_text_asset_patches( translated_text.as_bytes().to_vec(), ); patch.expected_name = entry.asset_name.clone(); + let current_qa = glossary + .as_ref() + .map(|glossary| evaluate_glossary_entry(glossary, current, translated_text)) + .transpose()?; + if let Some(qa) = current_qa.as_ref() { + validate_current_glossary_qa(qa, entry.glossary_override.as_ref())?; + } else if entry.glossary_override.is_some() { + return Err(anyhow::anyhow!( + "TextUnit {} 存在 Glossary override,但当前没有 Glossary 数据库", + entry.id + )); + } patches.push(LocalizedTextAssetPatch { bundle_path: entry.destination.clone(), text_asset: patch, - metadata: Some(localized_patch_metadata(entry)), + metadata: Some(localized_patch_metadata( + entry, + current_qa.as_ref(), + entry.glossary_override.as_ref(), + )), }); } @@ -773,7 +828,11 @@ pub fn localized_text_asset_patches( Ok(patches) } -fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatchOperationMetadata { +fn localized_patch_metadata( + entry: &TranslationWorkbenchEntry, + glossary_qa: Option<&GlossaryQaReport>, + glossary_override: Option<&GlossaryOverride>, +) -> LocalizedPatchOperationMetadata { LocalizedPatchOperationMetadata { text_unit_id: entry.id.clone(), source_text_blake3: blake3::hash(entry.source_text.as_bytes()) @@ -787,8 +846,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(), + glossary_qa: glossary_qa.cloned(), + glossary_override: glossary_override.cloned(), } } @@ -1251,6 +1310,172 @@ mod tests { assert_eq!(report.unreviewed_entries, 0); } + #[test] + fn checked_translation_rejects_override_after_glossary_change() { + let temp = tempfile::TempDir::new().unwrap(); + let index = crate::official_parse::OfficialTextUnitIndex { + version: crate::official_parse::OFFICIAL_TEXTUNIT_INDEX_VERSION, + generated_unix_seconds: 1, + resource_root: temp.path().to_path_buf(), + summary: Default::default(), + units: vec![OfficialTextUnitIndexUnit { + id: "unit-1".to_string(), + parse_entry_key: "bundle".to_string(), + source_url: "https://example.invalid/bundle".to_string(), + destination: "bundles/test.bundle".to_string(), + archive_entry: None, + source_kind: crate::official_parse::OfficialParseSourceKind::DirectBundle, + unity_version: None, + source_text: "原文".to_string(), + serialized_file: Some("CAB-test".to_string()), + path_id: Some(7), + class_id: Some(49), + field_path: None, + field_offset: None, + field_byte_size: None, + format: Some("plain".to_string()), + text_source_kind: Some("text_asset".to_string()), + asset_name: Some("Story".to_string()), + context: Default::default(), + }], + errors: Vec::new(), + }; + crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap(); + let workbench_path = temp.path().join("workbench.json"); + write_translation_workbench(&workbench_path, &workbench(temp.path())).unwrap(); + let glossary_path = temp.path().join("glossary.sqlite"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let qa = runtime.block_on(async { + let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap(); + glossary + .add(bat_core::domain::GlossaryTermDraft { + term_id: "term-source".to_string(), + definition: bat_core::domain::GlossaryTermSnapshot { + source_term: "原文".to_string(), + aliases: Vec::new(), + recommended_translation: "译文".to_string(), + allowed_translations: Vec::new(), + source_language: None, + target_language: None, + category: None, + priority: 1, + scope: BTreeMap::new(), + }, + review_status: bat_core::domain::GlossaryReviewStatus::Draft, + source: bat_core::domain::GlossarySourceRecord { + source_kind: bat_core::domain::GlossarySourceKind::Manual, + source_ref: None, + source_author: None, + source_note: None, + observed_unix_seconds: 1, + }, + }) + .await + .unwrap(); + glossary + .review( + "term-source", + bat_core::domain::GlossaryReviewStatus::Approved, + "reviewer", + Some("approve".to_string()), + ) + .await + .unwrap(); + let context = crate::translation_memory::translation_memory_context( + "bundles/test.bundle", + None, + Some("CAB-test"), + Some(7), + Some(49), + None, + Some("plain"), + Some("Story"), + Some("text_asset"), + &BTreeMap::new(), + ); + let evaluation = glossary.diagnose("原文", &context).await.unwrap(); + (glossary, evaluation.check_translation("错误")) + }); + let (glossary, qa) = qa; + let override_record = GlossaryOverride { + qa_identity: qa.qa_identity.clone(), + reviewer: "reviewer".to_string(), + reason: "manual review".to_string(), + provenance: "workbench".to_string(), + confirmed_unix_seconds: 1, + }; + let updated = set_translation_checked_with_glossary_path( + temp.path(), + &workbench_path, + "unit-1", + "错误".to_string(), + Some(override_record.clone()), + Some(&glossary_path), + ) + .unwrap(); + assert_eq!( + updated + .glossary_qa + .as_ref() + .map(|report| report.qa_identity.as_str()), + Some(qa.qa_identity.as_str()) + ); + let loaded = read_translation_workbench(&workbench_path).unwrap(); + let validation = validate_translation_workbench_with_glossary_path( + temp.path(), + "release-1", + &loaded, + Some(&glossary_path), + ) + .unwrap(); + assert_eq!(validation.status, "valid"); + + runtime.block_on(async { + let current = glossary.find("term-source").await.unwrap(); + glossary + .update( + bat_core::domain::GlossaryTermDraft { + term_id: current.term_id, + definition: bat_core::domain::GlossaryTermSnapshot { + recommended_translation: "新译文".to_string(), + ..current.definition + }, + review_status: bat_core::domain::GlossaryReviewStatus::Draft, + source: bat_core::domain::GlossarySourceRecord { + observed_unix_seconds: 2, + ..current.source + }, + }, + "reviewer", + Some("change recommendation".to_string()), + ) + .await + .unwrap(); + glossary + .review( + "term-source", + bat_core::domain::GlossaryReviewStatus::Approved, + "reviewer", + Some("approve changed term".to_string()), + ) + .await + .unwrap(); + }); + let error = set_translation_checked_with_glossary_path( + temp.path(), + &workbench_path, + "unit-1", + "错误".to_string(), + Some(override_record), + Some(&glossary_path), + ) + .unwrap_err(); + assert!(error.to_string().contains("qa_identity")); + } + #[test] fn localized_operations_preserve_type_tree_field_traceability() { let temp = tempfile::TempDir::new().unwrap(); @@ -1290,6 +1515,59 @@ mod tests { entry.translated_text = Some("译文".to_string()); entry.translation_provider = Some("mock".to_string()); entry.provider_run_id = Some("run-1".to_string()); + entry.glossary_qa = Some(GlossaryQaReport { + qa_identity: "stale-workbench-qa".to_string(), + status: bat_core::domain::GlossaryQaStatus::Pass, + constraints: Vec::new(), + diagnostics: Vec::new(), + }); + let glossary_path = temp.path().join("glossary.sqlite"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let expected_qa = runtime.block_on(async { + let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap(); + glossary + .add(bat_core::domain::GlossaryTermDraft { + term_id: "term-source".to_string(), + definition: bat_core::domain::GlossaryTermSnapshot { + source_term: "原文".to_string(), + aliases: Vec::new(), + recommended_translation: "译文".to_string(), + allowed_translations: Vec::new(), + source_language: None, + target_language: None, + category: None, + priority: 1, + scope: BTreeMap::new(), + }, + review_status: bat_core::domain::GlossaryReviewStatus::Draft, + source: bat_core::domain::GlossarySourceRecord { + source_kind: bat_core::domain::GlossarySourceKind::Manual, + source_ref: None, + source_author: None, + source_note: None, + observed_unix_seconds: 1, + }, + }) + .await + .unwrap(); + glossary + .review( + "term-source", + bat_core::domain::GlossaryReviewStatus::Approved, + "reviewer", + Some("approve".to_string()), + ) + .await + .unwrap(); + glossary + .diagnose("原文", &BTreeMap::new()) + .await + .unwrap() + .check_translation("译文") + }); let operations = localized_patch_operations(temp.path(), &workbench).unwrap(); assert_eq!(operations.len(), 1); @@ -1305,6 +1583,11 @@ mod tests { assert_eq!(metadata.text_unit_id, "unit-1"); assert_eq!(metadata.translation_provider.as_deref(), Some("mock")); assert_eq!(metadata.provider_run_id.as_deref(), Some("run-1")); + assert_eq!( + metadata.glossary_qa.as_ref().map(|qa| qa.status), + Some(bat_core::domain::GlossaryQaStatus::Pass) + ); + assert_eq!(metadata.glossary_qa.as_ref(), Some(&expected_qa)); } other => panic!("unexpected localized operation: {other:?}"), } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 052c99f..1c8cad3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1944,7 +1944,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { {name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"}, {name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"}, {name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"}, - {name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文","glossary_override":{"reviewer":"reviewer","reason":"approved deviation","provenance":"manual-review","confirmed_unix_seconds":100}}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"}, + {name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文","glossary_override":{"qa_identity":"gqa-v1-test","reviewer":"reviewer","reason":"approved deviation","provenance":"manual-review","confirmed_unix_seconds":100}}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"}, {name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"}, {name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"}, {name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"}, @@ -1983,7 +1983,8 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { len(backend.translationTaskUpdates[0].TranslationResults) != 1 || backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" || backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride == nil || - backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.Reviewer != "reviewer" { + backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.Reviewer != "reviewer" || + backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.QAIdentity != "gqa-v1-test" { t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates) } if len(backend.translationMemoryConfirmParams) != 1 || diff --git a/internal/api/openapi.go b/internal/api/openapi.go index bb1c4ab..f2b9d77 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -534,7 +534,7 @@ paths: type: string responses: "200": - description: Glossary constraints, diagnostics, and blocked decision. + description: Glossary constraints, diagnostics, blocked decision, and stable qa_identity. "400": description: Missing source text or invalid context. "401": @@ -628,9 +628,12 @@ paths: type: string glossary_override: type: object - required: [reviewer, reason, provenance, confirmed_unix_seconds] + required: [qa_identity, reviewer, reason, provenance, confirmed_unix_seconds] additionalProperties: false properties: + qa_identity: + type: string + minLength: 1 reviewer: type: string reason: diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 19a9693..df2ec15 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -513,6 +513,7 @@ type GlossaryTermSnapshot struct { // GlossaryOverride records explicit human approval for a deviation. type GlossaryOverride struct { + QAIdentity string `json:"qa_identity"` Reviewer string `json:"reviewer"` Reason string `json:"reason"` Provenance string `json:"provenance"` diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index df41f68..c886588 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -464,6 +464,7 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) { SourceText: "source", TranslatedText: "译文", GlossaryOverride: &GlossaryOverride{ + QAIdentity: "gqa-v1-test", Reviewer: "reviewer", Reason: "approved deviation", Provenance: "manual-review",