feat(glossary): 实现 Rust Glossary V1

This commit is contained in:
2026-09-07 22:38:53 +08:00
parent 8fc93b8f39
commit 94483ff14d
42 changed files with 4543 additions and 92 deletions
+690
View File
@@ -0,0 +1,690 @@
//! Glossary V1 domain objects and deterministic term QA.
use std::collections::{BTreeMap, BTreeSet};
/// Glossary review state. Only approved terms participate in automation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GlossaryReviewStatus {
/// Imported or manually entered term awaiting review.
Draft,
/// Term explicitly approved for provider/TM automation.
Approved,
/// Term retained for history but no longer active.
Deprecated,
/// Term rejected by review.
Rejected,
}
impl GlossaryReviewStatus {
/// Stable persistence label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Approved => "approved",
Self::Deprecated => "deprecated",
Self::Rejected => "rejected",
}
}
/// Parses a stable persistence label.
pub fn parse(value: &str) -> Option<Self> {
match value {
"draft" => Some(Self::Draft),
"approved" => Some(Self::Approved),
"deprecated" => Some(Self::Deprecated),
"rejected" => Some(Self::Rejected),
_ => None,
}
}
/// Whether this term is eligible for automatic application.
pub const fn is_approved(self) -> bool {
matches!(self, Self::Approved)
}
}
/// Origin of a glossary term.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GlossarySourceKind {
/// Explicitly entered by a maintainer or reviewer.
Manual,
/// Imported from an external glossary artifact.
Imported,
}
impl GlossarySourceKind {
/// Stable persistence label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Imported => "imported",
}
}
/// Parses a stable persistence label.
pub fn parse(value: &str) -> Option<Self> {
match value {
"manual" => Some(Self::Manual),
"imported" => Some(Self::Imported),
_ => None,
}
}
}
/// Provenance of the current term definition.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossarySourceRecord {
/// Source classification.
pub source_kind: GlossarySourceKind,
/// Stable source reference, such as an import file or issue.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_ref: Option<String>,
/// Person or system that supplied the source.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_author: Option<String>,
/// Source note.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_note: Option<String>,
/// Time this source was observed.
pub observed_unix_seconds: u64,
}
/// One historical glossary mutation.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryHistoryRecord {
/// Stable history row ID.
pub history_id: String,
/// Mutation action (`created`, `updated`, `approved`, ...).
pub action: String,
/// Reviewer responsible for the mutation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewer: Option<String>,
/// Human reason for the mutation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// Snapshot of the source provenance at that point.
pub source: GlossarySourceRecord,
/// Review state after the mutation.
pub review_status: GlossaryReviewStatus,
/// Complete term snapshot, excluding history.
pub snapshot: GlossaryTermSnapshot,
/// Mutation time.
pub observed_unix_seconds: u64,
}
/// Serializable term data captured in source history.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryTermSnapshot {
/// Source-language term.
pub source_term: String,
/// Alternative source-language spellings.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
/// Recommended target translation.
pub recommended_translation: String,
/// Other target translations explicitly allowed by review.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_translations: Vec<String>,
/// Optional source language.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_language: Option<String>,
/// Optional target language.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_language: Option<String>,
/// Optional category.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Higher values win an explicit local conflict.
pub priority: i32,
/// Empty scope means global.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub scope: BTreeMap<String, String>,
}
/// A project-level approved or historical glossary term.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryTerm {
/// Stable term ID.
pub term_id: String,
/// Current definition.
#[serde(flatten)]
pub definition: GlossaryTermSnapshot,
/// Current review state.
pub review_status: GlossaryReviewStatus,
/// Current source provenance.
pub source: GlossarySourceRecord,
/// Full source/review history.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<GlossaryHistoryRecord>,
/// Creation time.
pub created_unix_seconds: u64,
/// Last mutation time.
pub updated_unix_seconds: u64,
}
impl GlossaryTerm {
/// Returns a snapshot suitable for history persistence.
pub fn snapshot(&self) -> GlossaryTermSnapshot {
self.definition.clone()
}
}
/// Input used to create or replace a term definition.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryTermDraft {
/// Stable term ID.
pub term_id: String,
/// Current definition.
#[serde(flatten)]
pub definition: GlossaryTermSnapshot,
/// Initial/current review state.
pub review_status: GlossaryReviewStatus,
/// Current source provenance.
pub source: GlossarySourceRecord,
}
/// Counts of glossary terms by review state.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossarySummary {
/// SQLite schema version.
pub schema_version: u32,
/// Total term count.
pub term_count: u64,
/// Approved term count.
pub approved_count: u64,
/// Draft term count.
pub draft_count: u64,
/// Deprecated term count.
pub deprecated_count: u64,
/// Rejected term count.
pub rejected_count: u64,
}
/// Provider-neutral glossary constraint attached to a TextUnit.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryConstraint {
/// Stable matched term ID.
pub term_id: String,
/// Source spelling found in the TextUnit.
pub matched_source: String,
/// Recommended target translation.
pub recommended_translation: String,
/// Explicitly allowed target translations.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_translations: Vec<String>,
/// Optional category.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Term priority.
pub priority: i32,
/// Matching scope.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub scope: BTreeMap<String, String>,
}
/// Deterministic diagnostic kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GlossaryDiagnosticKind {
/// Equal-precedence terms require an explicit decision.
Conflict,
/// A lower-precedence term was explicitly overridden by a higher one.
Overridden,
/// The output contains neither the recommended nor an allowed translation.
Violation,
/// The output uses an allowed but non-recommended translation.
NonRecommended,
}
impl GlossaryDiagnosticKind {
/// Stable diagnostic label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Conflict => "conflict",
Self::Overridden => "overridden",
Self::Violation => "violation",
Self::NonRecommended => "non_recommended",
}
}
}
/// One glossary matching or QA diagnostic.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryDiagnostic {
/// Diagnostic kind.
pub kind: GlossaryDiagnosticKind,
/// Term ID, when tied to one term.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub term_id: Option<String>,
/// Source spelling or target text involved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
/// Stable human-readable detail.
pub message: String,
}
/// Result of applying approved terms to one TextUnit source.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryEvaluation {
/// Approved constraints sent to a provider.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraints: Vec<GlossaryConstraint>,
/// Matching diagnostics, including explicit overrides and conflicts.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<GlossaryDiagnostic>,
/// Whether a provider/TM result must not be automatically accepted.
pub blocked: bool,
}
impl GlossaryEvaluation {
/// Runs deterministic output QA against the approved constraints.
pub fn check_translation(&self, translated_text: &str) -> GlossaryQaReport {
let mut diagnostics = self.diagnostics.clone();
let mut blocked = self.blocked;
for constraint in &self.constraints {
let mut accepted = vec![(constraint.recommended_translation.as_str(), true)];
accepted.extend(
constraint
.allowed_translations
.iter()
.map(|value| (value.as_str(), false)),
);
accepted.sort_by(|left, right| {
right
.0
.len()
.cmp(&left.0.len())
.then_with(|| left.0.cmp(right.0))
});
if let Some((value, recommended)) = accepted
.into_iter()
.find(|(value, _)| !value.is_empty() && translated_text.contains(value))
{
if !recommended {
diagnostics.push(GlossaryDiagnostic {
kind: GlossaryDiagnosticKind::NonRecommended,
term_id: Some(constraint.term_id.clone()),
value: Some(value.to_string()),
message: format!(
"TextUnit 使用了术语 {} 的允许但非推荐译法",
constraint.matched_source
),
});
}
} else {
blocked = true;
diagnostics.push(GlossaryDiagnostic {
kind: GlossaryDiagnosticKind::Violation,
term_id: Some(constraint.term_id.clone()),
value: Some(constraint.matched_source.clone()),
message: format!(
"TextUnit 中的术语 {} 未使用推荐或允许译法",
constraint.matched_source
),
});
}
}
GlossaryQaReport {
status: if blocked {
GlossaryQaStatus::Blocked
} else if diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::NonRecommended)
{
GlossaryQaStatus::Warning
} else {
GlossaryQaStatus::Pass
},
constraints: self.constraints.clone(),
diagnostics,
}
}
}
/// QA status of a concrete translation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GlossaryQaStatus {
/// Every matched term uses the recommendation.
Pass,
/// An explicitly allowed alternative was used.
Warning,
/// A conflict or unapproved translation requires review.
Blocked,
}
impl GlossaryQaStatus {
/// Stable status label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Warning => "warning",
Self::Blocked => "blocked",
}
}
/// Whether publication requires explicit human confirmation.
pub const fn is_blocked(self) -> bool {
matches!(self, Self::Blocked)
}
}
/// Persisted glossary QA attached to a translation result.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryQaReport {
/// QA status.
pub status: GlossaryQaStatus,
/// Constraints evaluated.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constraints: Vec<GlossaryConstraint>,
/// Deterministic diagnostics.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<GlossaryDiagnostic>,
}
/// Explicit human approval to deviate from a blocking glossary result.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GlossaryOverride {
/// Reviewer identity.
pub reviewer: String,
/// Required reason.
pub reason: String,
/// Provenance of the confirmation action.
pub provenance: String,
/// Confirmation time.
pub confirmed_unix_seconds: u64,
}
/// Applies only approved terms to one source/context pair.
pub fn evaluate_glossary(
terms: &[GlossaryTerm],
source_text: &str,
context: &BTreeMap<String, String>,
) -> GlossaryEvaluation {
#[derive(Clone)]
struct Candidate {
term_id: String,
matched_source: String,
recommendation: String,
allowed: Vec<String>,
category: Option<String>,
priority: i32,
scope: BTreeMap<String, String>,
start: usize,
end: usize,
specificity: usize,
}
let mut candidates = Vec::new();
for term in terms.iter().filter(|term| term.review_status.is_approved()) {
if !term
.definition
.scope
.iter()
.all(|(key, value)| context.get(key) == Some(value))
{
continue;
}
let mut spellings = vec![term.definition.source_term.clone()];
spellings.extend(term.definition.aliases.clone());
spellings.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
spellings.dedup();
for spelling in spellings.into_iter().filter(|value| !value.is_empty()) {
for (start, _) in source_text.match_indices(&spelling) {
candidates.push(Candidate {
term_id: term.term_id.clone(),
matched_source: spelling.clone(),
recommendation: term.definition.recommended_translation.clone(),
allowed: term.definition.allowed_translations.clone(),
category: term.definition.category.clone(),
priority: term.definition.priority,
scope: term.definition.scope.clone(),
start,
end: start + spelling.len(),
specificity: term.definition.scope.len(),
});
}
}
}
candidates.sort_by(|left, right| {
right
.priority
.cmp(&left.priority)
.then_with(|| right.specificity.cmp(&left.specificity))
.then_with(|| (right.end - right.start).cmp(&(left.end - left.start)))
.then_with(|| left.start.cmp(&right.start))
.then_with(|| left.term_id.cmp(&right.term_id))
.then_with(|| left.matched_source.cmp(&right.matched_source))
});
let mut selected = Vec::new();
let mut diagnostics = Vec::new();
let mut blocked = false;
for candidate in candidates {
let overlapping = selected.iter().find(|selected: &&Candidate| {
candidate.start < selected.end && selected.start < candidate.end
});
if let Some(selected) = overlapping {
let same_precedence = candidate.priority == selected.priority
&& candidate.specificity == selected.specificity
&& candidate.end - candidate.start == selected.end - selected.start;
if candidate.recommendation != selected.recommendation {
if same_precedence {
blocked = true;
diagnostics.push(GlossaryDiagnostic {
kind: GlossaryDiagnosticKind::Conflict,
term_id: Some(candidate.term_id.clone()),
value: Some(candidate.matched_source.clone()),
message: format!(
"术语 {} 与 {} 在同一 TextUnit 位置产生不同推荐译法",
selected.matched_source, candidate.matched_source
),
});
} else {
diagnostics.push(GlossaryDiagnostic {
kind: GlossaryDiagnosticKind::Overridden,
term_id: Some(candidate.term_id.clone()),
value: Some(candidate.matched_source.clone()),
message: format!(
"术语 {} 被优先级更高或范围更具体的术语覆盖",
candidate.matched_source
),
});
}
}
continue;
}
selected.push(candidate);
}
selected.sort_by(|left, right| {
left.start
.cmp(&right.start)
.then_with(|| left.term_id.cmp(&right.term_id))
.then_with(|| left.matched_source.cmp(&right.matched_source))
});
let mut seen = BTreeSet::new();
let constraints = selected
.into_iter()
.filter(|candidate| {
seen.insert((
candidate.term_id.clone(),
candidate.matched_source.clone(),
candidate.start,
))
})
.map(|candidate| GlossaryConstraint {
term_id: candidate.term_id,
matched_source: candidate.matched_source,
recommended_translation: candidate.recommendation,
allowed_translations: candidate.allowed,
category: candidate.category,
priority: candidate.priority,
scope: candidate.scope,
})
.collect();
GlossaryEvaluation {
constraints,
diagnostics,
blocked,
}
}
/// Validates and normalizes a term draft without choosing a review status.
pub fn validate_glossary_draft(draft: &GlossaryTermDraft) -> crate::Result<()> {
if draft.term_id.trim().is_empty()
|| draft.definition.source_term.trim().is_empty()
|| draft.definition.recommended_translation.trim().is_empty()
{
return Err(crate::Error::InvalidArgument(
"Glossary term_id、source_term 和 recommended_translation 不能为空".to_string(),
));
}
if draft
.definition
.allowed_translations
.iter()
.any(|value| value.trim().is_empty())
{
return Err(crate::Error::InvalidArgument(
"Glossary allowed_translations 不能包含空字符串".to_string(),
));
}
let accepted = draft
.definition
.allowed_translations
.iter()
.chain(std::iter::once(&draft.definition.recommended_translation));
if draft.definition.recommended_translation.trim().is_empty()
|| accepted.clone().any(|value| value.trim().is_empty())
{
return Err(crate::Error::InvalidArgument(
"Glossary translation 不能包含空字符串".to_string(),
));
}
if draft
.definition
.scope
.iter()
.any(|(key, value)| key.trim().is_empty() || value.trim().is_empty())
{
return Err(crate::Error::InvalidArgument(
"Glossary scope 的键和值不能为空".to_string(),
));
}
if draft.source.observed_unix_seconds == 0 {
return Err(crate::Error::InvalidArgument(
"Glossary source observed_unix_seconds 必须大于 0".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn term(
term_id: &str,
source: &str,
translation: &str,
priority: i32,
scope: BTreeMap<String, String>,
) -> GlossaryTerm {
GlossaryTerm {
term_id: term_id.to_string(),
definition: GlossaryTermSnapshot {
source_term: source.to_string(),
aliases: Vec::new(),
recommended_translation: translation.to_string(),
allowed_translations: Vec::new(),
source_language: None,
target_language: None,
category: None,
priority,
scope,
},
review_status: GlossaryReviewStatus::Approved,
source: GlossarySourceRecord {
source_kind: GlossarySourceKind::Manual,
source_ref: None,
source_author: Some("test".to_string()),
source_note: None,
observed_unix_seconds: 1,
},
history: Vec::new(),
created_unix_seconds: 1,
updated_unix_seconds: 1,
}
}
#[test]
fn only_approved_terms_and_matching_scopes_are_constraints() {
let mut local = BTreeMap::new();
local.insert("destination".to_string(), "story".to_string());
let mut draft = term("draft", "Sensei", "老师", 10, BTreeMap::new());
draft.review_status = GlossaryReviewStatus::Draft;
let terms = vec![
draft,
term("global", "Blue Archive", "蔚蓝档案", 1, BTreeMap::new()),
term("local", "Sensei", "老师", 2, local.clone()),
];
let evaluation = evaluate_glossary(&terms, "Blue Archive Sensei", &local);
assert_eq!(evaluation.constraints.len(), 2);
assert!(evaluation
.constraints
.iter()
.any(|constraint| constraint.term_id == "global"));
assert!(evaluation
.constraints
.iter()
.any(|constraint| constraint.term_id == "local"));
}
#[test]
fn equal_precedence_conflicts_block_automatic_use() {
let terms = vec![
term("a", "Sensei", "老师", 1, BTreeMap::new()),
term("b", "Sensei", "导师", 1, BTreeMap::new()),
];
let evaluation = evaluate_glossary(&terms, "Sensei", &BTreeMap::new());
assert!(evaluation.blocked);
assert!(evaluation
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Conflict));
}
#[test]
fn allowed_alternative_is_warning_but_unknown_translation_is_blocked() {
let mut term = term("a", "Sensei", "老师", 1, BTreeMap::new());
term.definition.allowed_translations = vec!["导师".to_string(), "老师大人".to_string()];
let evaluation = evaluate_glossary(&[term.clone()], "Sensei", &BTreeMap::new());
let warning = evaluation.check_translation("导师");
assert_eq!(warning.status, GlossaryQaStatus::Warning);
let longer_warning = evaluation.check_translation("老师大人");
assert_eq!(longer_warning.status, GlossaryQaStatus::Warning);
let blocked = evaluation.check_translation("先生");
assert_eq!(blocked.status, GlossaryQaStatus::Blocked);
assert!(blocked
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Violation));
}
#[test]
fn higher_priority_term_wins_even_when_it_starts_later() {
let terms = vec![
term("low", "Blue Archive", "蔚蓝档案", 1, BTreeMap::new()),
term("high", "Archive", "档案库", 10, BTreeMap::new()),
];
let evaluation = evaluate_glossary(&terms, "Blue Archive", &BTreeMap::new());
assert_eq!(evaluation.constraints.len(), 1);
assert_eq!(evaluation.constraints[0].term_id, "high");
assert!(evaluation
.diagnostics
.iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Overridden));
}
}
+7
View File
@@ -2,12 +2,19 @@
pub mod game_client;
pub mod game_version;
pub mod glossary;
pub mod resource;
pub mod translation;
pub mod translation_memory;
pub use game_client::{ClientStatus, GameClient, GameRegion};
pub use game_version::{GameVersion, UnityVersion};
pub use glossary::{
evaluate_glossary, validate_glossary_draft, GlossaryConstraint, GlossaryDiagnostic,
GlossaryDiagnosticKind, GlossaryEvaluation, GlossaryHistoryRecord, GlossaryOverride,
GlossaryQaReport, GlossaryQaStatus, GlossaryReviewStatus, GlossarySourceKind,
GlossarySourceRecord, GlossarySummary, GlossaryTerm, GlossaryTermDraft, GlossaryTermSnapshot,
};
pub use resource::{
crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType,
};
@@ -0,0 +1,15 @@
//! Glossary repository boundary.
use crate::domain::{GlossaryEvaluation, TranslationMemoryContext};
use async_trait::async_trait;
/// Read-only matching boundary consumed by translation workers.
#[async_trait]
pub trait GlossaryRepository: Send + Sync {
/// Evaluates approved terms against one source TextUnit.
async fn evaluate(
&self,
source_text: &str,
context: &TranslationMemoryContext,
) -> crate::Result<GlossaryEvaluation>;
}
+2
View File
@@ -3,11 +3,13 @@
//! 定义所有数据访问接口
pub mod cas_repository;
pub mod glossary_repository;
pub mod resource_repository;
pub mod translation_memory_repository;
pub mod translation_repository;
pub use cas_repository::CasRepository;
pub use glossary_repository::GlossaryRepository;
pub use resource_repository::ResourceRepository;
pub use translation_memory_repository::TranslationMemoryRepository;
pub use translation_repository::TranslationRepository;