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
+167
View File
@@ -1,5 +1,6 @@
//! Manual translation workbench and controlled UnityFS repack workflows.
use crate::glossary::SqliteGlossaryRepository;
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
use crate::path_security::{
@@ -15,6 +16,7 @@ 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 serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
@@ -90,6 +92,12 @@ pub struct TranslationWorkbenchEntry {
/// Extraction source kind such as TextAsset or TypeTreeField.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_source_kind: Option<String>,
/// Deterministic Glossary QA for the current translation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<GlossaryQaReport>,
/// Explicit human confirmation for a blocking Glossary deviation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<GlossaryOverride>,
}
/// Summary produced by `i18n validate`.
@@ -274,6 +282,73 @@ pub fn set_translation(
.find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = Some(translated_text);
entry.glossary_qa = None;
entry.glossary_override = None;
let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?;
Ok(updated)
}
/// Updates one translation and evaluates the project Glossary.
pub fn set_translation_checked(
resource_root: &Path,
workbench_path: &Path,
entry_id: &str,
translated_text: String,
glossary_override: Option<GlossaryOverride>,
) -> anyhow::Result<TranslationWorkbenchEntry> {
set_translation_checked_with_glossary_path(
resource_root,
workbench_path,
entry_id,
translated_text,
glossary_override,
None,
)
}
/// Updates one translation using an optional configured Glossary path.
pub fn set_translation_checked_with_glossary_path(
resource_root: &Path,
workbench_path: &Path,
entry_id: &str,
translated_text: String,
glossary_override: Option<GlossaryOverride>,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<TranslationWorkbenchEntry> {
let mut workbench = read_translation_workbench(workbench_path)?;
let current = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?
.units
.into_iter()
.find(|unit| unit.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("当前 release 不存在 TextUnit{entry_id}"))?;
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 qa = glossary
.as_ref()
.map(|glossary| evaluate_glossary_entry(glossary, &current, &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;
} else if glossary_override.is_some() {
return Err(anyhow::anyhow!(
"Glossary override 只能用于存在 blocking QA 的译文"
));
}
let entry = workbench
.entries
.iter_mut()
.find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = Some(translated_text);
entry.glossary_qa = qa;
entry.glossary_override = glossary_override;
let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?;
@@ -305,6 +380,8 @@ pub fn unset_translation(
.find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = None;
entry.glossary_qa = None;
entry.glossary_override = None;
let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?;
@@ -320,6 +397,21 @@ pub fn validate_translation_workbench(
resource_root: &Path,
official_release_id: &str,
workbench: &TranslationWorkbench,
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
validate_translation_workbench_with_glossary_path(
resource_root,
official_release_id,
workbench,
None,
)
}
/// Validates a workbench using an optional configured Glossary path.
pub fn validate_translation_workbench_with_glossary_path(
resource_root: &Path,
official_release_id: &str,
workbench: &TranslationWorkbench,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?;
if workbench.official_release_id != official_release_id {
@@ -349,6 +441,10 @@ pub fn validate_translation_workbench(
let mut changed_entries = 0;
let mut publishable_entries = 0;
let mut repack_entries = 0;
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)?;
for entry in &workbench.entries {
if !seen_ids.insert(entry.id.as_str()) {
@@ -366,6 +462,12 @@ pub fn validate_translation_workbench(
unchanged_entries += 1;
continue;
}
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())?;
}
}
changed_entries += 1;
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
let is_publishable = entry.archive_entry.is_none()
@@ -419,6 +521,63 @@ pub fn validate_translation_workbench(
})
}
fn open_glossary_if_present(path: &Path) -> anyhow::Result<Option<SqliteGlossaryRepository>> {
if std::fs::symlink_metadata(path).is_err() {
return Ok(None);
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime
.block_on(SqliteGlossaryRepository::open(path))
.map(Some)
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))
}
fn evaluate_glossary_entry(
glossary: &SqliteGlossaryRepository,
unit: &OfficialTextUnitIndexUnit,
translated_text: &str,
) -> anyhow::Result<GlossaryQaReport> {
let context = crate::translation_memory::translation_memory_context(
&unit.destination,
unit.archive_entry.as_deref(),
unit.serialized_file.as_deref(),
unit.path_id,
unit.class_id,
unit.field_path.as_deref(),
unit.format.as_deref(),
unit.asset_name.as_deref(),
unit.text_source_kind.as_deref(),
&unit.context,
);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime
.block_on(glossary.diagnose(&unit.source_text, &context))
.map(|evaluation| evaluation.check_translation(translated_text))
.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 必须有效"
));
}
Ok(())
}
/// Converts reviewed entries to localized patch operations supported by the
/// current UnityFS write layer.
///
@@ -628,6 +787,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(),
}
}
@@ -911,6 +1072,8 @@ fn workbench_entry_from_worker_result(
entry.translation_source_kind = Some(result.source_kind.as_str().to_string());
entry.translation_memory_record_id = result.translation_memory_record_id.clone();
entry.translated_unix_seconds = Some(result.translated_unix_seconds);
entry.glossary_qa = result.glossary_qa.clone();
entry.glossary_override = result.glossary_override.clone();
entry.review_status = Some(
match result.source_kind {
TranslationTaskResultSourceKind::Provider => "provider_completed",
@@ -964,6 +1127,8 @@ impl TranslationWorkbenchEntry {
review_status: None,
format: unit.format.clone(),
text_source_kind: unit.text_source_kind.clone(),
glossary_qa: None,
glossary_override: None,
}
}
}
@@ -1003,6 +1168,8 @@ mod tests {
review_status: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
glossary_qa: None,
glossary_override: None,
}],
}
}