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:
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user