mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
fix(glossary): 绑定 QA identity 与人工 override
This commit is contained in:
+2
-1
@@ -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。
|
||||
|
||||
---
|
||||
|
||||
Generated
+1
@@ -118,6 +118,7 @@ version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"blake3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
+5
-4
@@ -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 失效,无关术语变化不会使其失效。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+380
-13
@@ -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<GlossaryConstraint>,
|
||||
@@ -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<String>,
|
||||
category: Option<String>,
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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<GlossaryConstraint> = 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::<Vec<_>>();
|
||||
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<String, String>) {
|
||||
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]
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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 <text-unit-id> \
|
||||
--translated-text '人工确认的译文' \
|
||||
--glossary-qa-identity <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 不会被静默改写。
|
||||
|
||||
使用人工编辑的工作台发布:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 扩展体系未实现
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
glossary_reason: Option<String>,
|
||||
glossary_override_provenance: Option<String>,
|
||||
glossary_qa_identity: Option<String>,
|
||||
glossary_source_text: Option<String>,
|
||||
glossary_context_json: Option<String>,
|
||||
glossary_review_status: Option<String>,
|
||||
@@ -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;
|
||||
|
||||
@@ -448,6 +448,7 @@ Sync:
|
||||
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews/delete
|
||||
--glossary-reason <TEXT> Reason for Glossary review/delete or override
|
||||
--glossary-provenance <TEXT> Provenance for an explicit Glossary override
|
||||
--glossary-qa-identity <ID> Current blocking Glossary QA identity for an override
|
||||
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
|
||||
--worker-max-attempts <N> Maximum claims per translation task
|
||||
--worker-lease-seconds <N> Lease seconds for one claimed task
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::report_output::print_json_value;
|
||||
use super::*;
|
||||
use bat_core::domain::{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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 版本号
|
||||
|
||||
@@ -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<String>,
|
||||
/// Glossary QA recomputed for this publication.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
|
||||
/// Human confirmation bound to the published Glossary QA identity.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Vec<LocalizedPatchInput>> {
|
||||
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<Vec<LocalizedPatchInput>> {
|
||||
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::<std::collections::HashMap<_, _>>();
|
||||
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::<std::collections::HashMap<_, _>>();
|
||||
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:?}"),
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user