mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
feat(glossary): 实现 Rust Glossary V1
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user