fix(glossary): 绑定 QA identity 与人工 override
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-09 00:25:49 +08:00
parent 0275a890bc
commit 68c6c91b1e
22 changed files with 844 additions and 92 deletions
+1
View File
@@ -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
View File
@@ -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]
+3 -3
View File
@@ -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::{