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
+353 -3
View File
@@ -13,8 +13,9 @@ use bat_infrastructure::{
localized_patch_operations, open_append_file, read_download_manifest_at, read_file_no_symlink,
read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at,
read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state,
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation,
validate_output_root, validate_runtime_state_dir, validate_translation_workbench,
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
set_translation_checked_with_glossary_path, unset_translation, validate_output_root,
validate_runtime_state_dir, validate_translation_workbench_with_glossary_path,
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
@@ -54,6 +55,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[path = "config_file.rs"]
mod config_file;
#[path = "glossary_query.rs"]
mod glossary_query;
#[path = "patch_commands.rs"]
mod patch_commands;
#[path = "readonly_query.rs"]
@@ -70,6 +73,11 @@ mod terminal_output;
mod translation_query;
#[path = "workflow_commands.rs"]
mod workflow_commands;
use glossary_query::{
glossary_diagnose_rpc_report, glossary_mutation_rpc_report, glossary_query_rpc_report,
glossary_review_rpc_report, glossary_rpc_envelope, glossary_summary_rpc_report,
run_glossary_command,
};
use patch_commands::{
is_write_patch_command, run_write_patch_command, validate_write_patch_options,
};
@@ -229,6 +237,16 @@ fn run() -> anyhow::Result<i32> {
run_translation_memory_command(&options)?;
Ok(0)
}
CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose => {
run_glossary_command(&options)?;
Ok(0)
}
CliCommand::TranslationWorker => {
run_repeated_workflow(&options, "translation-worker", run_translation_worker)?;
Ok(0)
@@ -385,6 +403,8 @@ struct CliOptions {
translation_provider: Option<String>,
translation_fixture: Option<PathBuf>,
translation_memory_path: Option<PathBuf>,
glossary_path: Option<PathBuf>,
glossary_path_option_explicit: bool,
translation_memory_option_explicit: bool,
translation_memory_command_option_explicit: bool,
translation_memory_source_text: Option<String>,
@@ -392,6 +412,26 @@ struct CliOptions {
translation_memory_record_id: Option<String>,
translation_memory_reviewer: Option<String>,
translation_memory_reason: Option<String>,
glossary_term_id: Option<String>,
glossary_source_term: Option<String>,
glossary_aliases_json: Option<String>,
glossary_recommended_translation: Option<String>,
glossary_allowed_translations_json: Option<String>,
glossary_source_language: Option<String>,
glossary_target_language: Option<String>,
glossary_category: Option<String>,
glossary_priority: i32,
glossary_scope_json: Option<String>,
glossary_source_kind: Option<String>,
glossary_source_ref: Option<String>,
glossary_source_author: Option<String>,
glossary_source_note: Option<String>,
glossary_reviewer: Option<String>,
glossary_reason: Option<String>,
glossary_override_provenance: Option<String>,
glossary_source_text: Option<String>,
glossary_context_json: Option<String>,
glossary_review_status: Option<String>,
worker_concurrency: usize,
worker_max_attempts: u32,
worker_lease_seconds: u64,
@@ -488,6 +528,8 @@ impl Default for CliOptions {
translation_provider: None,
translation_fixture: None,
translation_memory_path: None,
glossary_path: None,
glossary_path_option_explicit: false,
translation_memory_option_explicit: false,
translation_memory_command_option_explicit: false,
translation_memory_source_text: None,
@@ -495,6 +537,26 @@ impl Default for CliOptions {
translation_memory_record_id: None,
translation_memory_reviewer: None,
translation_memory_reason: None,
glossary_term_id: None,
glossary_source_term: None,
glossary_aliases_json: None,
glossary_recommended_translation: None,
glossary_allowed_translations_json: None,
glossary_source_language: None,
glossary_target_language: None,
glossary_category: None,
glossary_priority: 0,
glossary_scope_json: None,
glossary_source_kind: None,
glossary_source_ref: None,
glossary_source_author: None,
glossary_source_note: None,
glossary_reviewer: None,
glossary_reason: None,
glossary_override_provenance: None,
glossary_source_text: None,
glossary_context_json: None,
glossary_review_status: None,
worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY,
worker_max_attempts: DEFAULT_TRANSLATION_MAX_ATTEMPTS,
worker_lease_seconds: DEFAULT_TRANSLATION_LEASE_SECONDS,
@@ -590,6 +652,13 @@ enum CliCommand {
TranslationMemorySummary,
TranslationMemoryQuery,
TranslationMemoryConfirm,
GlossarySummary,
GlossaryQuery,
GlossaryAdd,
GlossaryUpdate,
GlossaryApprove,
GlossaryDeprecate,
GlossaryDiagnose,
Repack,
PublishLocalized,
LocalizedRollback,
@@ -1107,6 +1176,13 @@ const RPC_METHOD_TRANSLATION_WORKER_RUN: &str = "translation.worker.run";
const RPC_METHOD_TRANSLATION_MEMORY_SUMMARY: &str = "translation.memory.summary";
const RPC_METHOD_TRANSLATION_MEMORY_QUERY: &str = "translation.memory.query";
const RPC_METHOD_TRANSLATION_MEMORY_CONFIRM: &str = "translation.memory.confirm";
const RPC_METHOD_GLOSSARY_SUMMARY: &str = "translation.glossary.summary";
const RPC_METHOD_GLOSSARY_QUERY: &str = "translation.glossary.query";
const RPC_METHOD_GLOSSARY_ADD: &str = "translation.glossary.add";
const RPC_METHOD_GLOSSARY_UPDATE: &str = "translation.glossary.update";
const RPC_METHOD_GLOSSARY_APPROVE: &str = "translation.glossary.approve";
const RPC_METHOD_GLOSSARY_DEPRECATE: &str = "translation.glossary.deprecate";
const RPC_METHOD_GLOSSARY_DIAGNOSE: &str = "translation.glossary.diagnose";
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
@@ -2105,7 +2181,11 @@ fn dispatch_rpc_method(
RPC_METHOD_TRANSLATION_TASK_UPDATE => rpc_envelope_from_result(
request_id,
"translation.task.update",
update_translation_task_status_report(state_dir, request.params.as_ref()),
update_translation_task_status_report(
state_dir,
request.params.as_ref(),
tasks.translation_worker_config.glossary_path.as_deref(),
),
),
RPC_METHOD_TRANSLATION_PROOFREAD => {
let _sync_guard = tasks
@@ -2155,6 +2235,67 @@ fn dispatch_rpc_method(
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_SUMMARY => glossary_rpc_envelope(
request_id,
glossary_summary_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_QUERY => glossary_rpc_envelope(
request_id,
glossary_query_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_DIAGNOSE => glossary_rpc_envelope(
request_id,
glossary_diagnose_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_ADD => glossary_rpc_envelope(
request_id,
glossary_mutation_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
false,
),
),
RPC_METHOD_GLOSSARY_UPDATE => glossary_rpc_envelope(
request_id,
glossary_mutation_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
true,
),
),
RPC_METHOD_GLOSSARY_APPROVE | RPC_METHOD_GLOSSARY_DEPRECATE => glossary_rpc_envelope(
request_id,
glossary_review_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
if request.method == RPC_METHOD_GLOSSARY_APPROVE {
bat_core::domain::GlossaryReviewStatus::Approved
} else {
bat_core::domain::GlossaryReviewStatus::Deprecated
},
),
),
RPC_METHOD_TRANSLATION_WORKER_RUN => {
let config = match rpc_translation_worker_config_with_defaults(
request.params.as_ref(),
@@ -3504,6 +3645,13 @@ fn rpc_translation_worker_config_with_defaults(
)?
.map(PathBuf::from)
.or_else(|| defaults.translation_memory_path.clone()),
glossary_path: rpc_translation_worker_string_param(
params,
&["glossary_path", "translation_glossary_path"],
"glossary_path",
)?
.map(PathBuf::from)
.or_else(|| defaults.glossary_path.clone()),
};
config.validate().map_err(|error| {
ApiError::new(
@@ -6109,6 +6257,10 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
args.push("--translation-memory-path".to_string());
args.push(path.to_string_lossy().to_string());
}
if let Some(path) = options.glossary_path.as_ref() {
args.push("--glossary-path".to_string());
args.push(path.to_string_lossy().to_string());
}
args.push("--worker-concurrency".to_string());
args.push(options.worker_concurrency.to_string());
args.push("--worker-max-attempts".to_string());
@@ -6420,6 +6572,9 @@ fn apply_bat_env_overrides(
{
options.translation_memory_path = Some(PathBuf::from(v));
}
if let Some(v) = value("BAT_GLOSSARY_PATH") {
options.glossary_path = Some(PathBuf::from(v));
}
if let Some(v) = value("BAT_TRANSLATION_CONCURRENCY") {
options.worker_concurrency =
parse_translation_worker_concurrency(&v, "环境变量 BAT_TRANSLATION_CONCURRENCY")?;
@@ -6702,6 +6857,10 @@ fn parse_args_with_env(
options.translation_memory_option_explicit = true;
options.translation_worker_option_explicit = true;
}
"--glossary-path" | "--translation-glossary-path" => {
options.glossary_path = Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.glossary_path_option_explicit = true;
}
"--tm-source-text" => {
options.translation_memory_source_text = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
@@ -6723,6 +6882,70 @@ fn parse_args_with_env(
options.translation_memory_reason = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
}
"--glossary-term-id" => {
options.glossary_term_id = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-term" => {
options.glossary_source_term = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-aliases-json" => {
options.glossary_aliases_json = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-recommended-translation" => {
options.glossary_recommended_translation =
Some(next_option_value(&mut args, &flag)?);
}
"--glossary-allowed-translations-json" => {
options.glossary_allowed_translations_json =
Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-language" => {
options.glossary_source_language = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-target-language" => {
options.glossary_target_language = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-category" => {
options.glossary_category = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-priority" => {
options.glossary_priority = next_option_value(&mut args, &flag)?
.parse()
.map_err(|error| anyhow::anyhow!("--glossary-priority 无效:{error}"))?;
}
"--glossary-scope-json" => {
options.glossary_scope_json = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-kind" => {
options.glossary_source_kind = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-ref" => {
options.glossary_source_ref = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-author" => {
options.glossary_source_author = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-note" => {
options.glossary_source_note = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-reviewer" => {
options.glossary_reviewer = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-reason" => {
options.glossary_reason = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-provenance" | "--glossary-override-provenance" => {
options.glossary_override_provenance = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-text" => {
options.glossary_source_text = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-context-json" => {
options.glossary_context_json = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-review-status" => {
options.glossary_review_status = Some(next_option_value(&mut args, &flag)?);
}
"--worker-concurrency" | "--translation-concurrency" => {
options.worker_concurrency = parse_translation_worker_concurrency(
&next_option_value(&mut args, &flag)?,
@@ -7240,6 +7463,30 @@ fn parse_args_with_env(
"翻译 worker 参数只适用于 i18n worker run 或 daemon restart/reload"
));
}
if options.glossary_path_option_explicit
&& !options.daemon_child
&& !matches!(
options.command,
CliCommand::TranslationWorker
| CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose
| CliCommand::TranslationSet
| CliCommand::TranslationValidate
| CliCommand::TranslationTaskUpdate
| CliCommand::PublishLocalized
| CliCommand::Restart
| CliCommand::Reload
)
{
return Err(anyhow::anyhow!(
"Glossary 路径参数只适用于 Glossary、翻译 worker、工作台/任务发布或 daemon restart/reload"
));
}
if options.translation_memory_option_explicit
&& !matches!(
options.command,
@@ -7247,6 +7494,13 @@ fn parse_args_with_env(
| CliCommand::TranslationMemorySummary
| CliCommand::TranslationMemoryQuery
| CliCommand::TranslationMemoryConfirm
| CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose
| CliCommand::Restart
| CliCommand::Reload
)
@@ -7369,6 +7623,74 @@ fn parse_args_with_env(
options.progress = false;
options.banner = false;
}
CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose => {
if options.watch
|| options.daemon
|| options.daemon_child
|| options.config.force
|| options.config.dry_run
|| options.run_count.is_some()
|| options.sync_option_explicit
|| options.output_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config)
{
return Err(anyhow::anyhow!(
"i18n glossary 命令只接受 --state-dir、Glossary 参数和 --json/--human"
));
}
match options.command {
CliCommand::GlossarySummary => {}
CliCommand::GlossaryQuery => {
if options.glossary_term_id.is_some()
|| options.glossary_recommended_translation.is_some()
|| options.glossary_reviewer.is_some()
{
return Err(anyhow::anyhow!("Glossary query 不接受 mutation 参数"));
}
}
CliCommand::GlossaryDiagnose => {
if options.glossary_source_text.is_none() {
return Err(anyhow::anyhow!(
"Glossary diagnose 必须指定 --glossary-source-text"
));
}
}
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
if options.glossary_term_id.is_none()
|| options.glossary_source_term.is_none()
|| options.glossary_recommended_translation.is_none()
{
return Err(anyhow::anyhow!(
"Glossary add/update 必须指定 term-id、source-term 和 recommended-translation"
));
}
if options.command == CliCommand::GlossaryUpdate
&& options.glossary_reviewer.is_none()
{
return Err(anyhow::anyhow!(
"Glossary update 必须指定 --glossary-reviewer"
));
}
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
if options.glossary_term_id.is_none() || options.glossary_reviewer.is_none() {
return Err(anyhow::anyhow!(
"Glossary review 必须指定 --glossary-term-id 和 --glossary-reviewer"
));
}
}
_ => unreachable!(),
}
options.progress = false;
options.banner = false;
}
CliCommand::PatchApply
| CliCommand::UnityFsPatchTextAsset
| CliCommand::UnityFsPatchStringField
@@ -7916,6 +8238,9 @@ fn parse_translation_command(
if action == "memory" || action == "tm" {
return parse_translation_memory_command(args, options);
}
if action == "glossary" || action == "terms" {
return parse_translation_glossary_command(args, options);
}
let command = match action.as_str() {
"run" => CliCommand::Translate,
"export" => CliCommand::Translate,
@@ -7956,6 +8281,30 @@ fn parse_translation_memory_command(
Ok(())
}
fn parse_translation_glossary_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
) -> anyhow::Result<()> {
let action = next_option_value(args, "translation glossary")?;
let command = match action.as_str() {
"summary" | "status" => CliCommand::GlossarySummary,
"query" | "find" => CliCommand::GlossaryQuery,
"add" | "create" => CliCommand::GlossaryAdd,
"update" | "edit" => CliCommand::GlossaryUpdate,
"approve" | "trust" => CliCommand::GlossaryApprove,
"deprecate" | "retire" => CliCommand::GlossaryDeprecate,
"diagnose" | "check" => CliCommand::GlossaryDiagnose,
other => {
return Err(anyhow::anyhow!(
"未知 translation glossary 二级命令:{other}"
))
}
};
ensure_command_not_set(options.command, &format!("translation glossary {action}"))?;
options.command = command;
Ok(())
}
fn parse_translation_worker_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
@@ -8086,6 +8435,7 @@ fn translation_worker_config_from_options(
.clone()
.unwrap_or_else(|| default_worker_id.to_string()),
translation_memory_path: options.translation_memory_path.clone(),
glossary_path: options.glossary_path.clone(),
};
config.validate()?;
Ok(config)
+105
View File
@@ -366,6 +366,77 @@ fn translation_memory_subcommands_reject_irrelevant_options() {
assert!(error.to_string().contains("query 参数"));
}
#[test]
fn glossary_commands_parse_and_validate() {
let query = parse(&[
"bat",
"i18n",
"glossary",
"query",
"--glossary-source-text",
"Sensei",
"--glossary-review-status",
"approved",
"--limit",
"5",
])
.unwrap();
assert_eq!(query.command, CliCommand::GlossaryQuery);
assert_eq!(query.glossary_source_text.as_deref(), Some("Sensei"));
assert_eq!(query.glossary_review_status.as_deref(), Some("approved"));
assert_eq!(query.query_limit, 5);
let add = parse(&[
"bat",
"i18n",
"glossary",
"add",
"--glossary-term-id",
"term-sensei",
"--glossary-source-term",
"Sensei",
"--glossary-recommended-translation",
"老师",
"--glossary-source-kind",
"manual",
"--glossary-scope-json",
r#"{"destination":"story.bundle"}"#,
"--glossary-path",
"/tmp/project-glossary.sqlite",
])
.unwrap();
assert_eq!(add.command, CliCommand::GlossaryAdd);
assert_eq!(add.glossary_term_id.as_deref(), Some("term-sensei"));
assert_eq!(add.glossary_priority, 0);
assert_eq!(
add.glossary_path,
Some(PathBuf::from("/tmp/project-glossary.sqlite"))
);
let diagnose = parse(&[
"bat",
"translation",
"glossary",
"diagnose",
"--glossary-source-text",
"Sensei",
"--glossary-context-json",
r#"{"destination":"story.bundle"}"#,
])
.unwrap();
assert_eq!(diagnose.command, CliCommand::GlossaryDiagnose);
assert!(parse(&["bat", "i18n", "glossary", "diagnose"]).is_err());
assert!(parse(&[
"bat",
"i18n",
"glossary",
"query",
"--glossary-term-id",
"term-sensei",
])
.is_err());
}
#[test]
fn translation_worker_env_defaults_apply() {
let options = parse_with_env(
@@ -765,6 +836,8 @@ fn translation_workbench_commands_read_update_and_clear_entries() {
review_status: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
glossary_qa: None,
glossary_override: None,
}],
};
bat_infrastructure::write_translation_workbench(&path, &workbench).unwrap();
@@ -2679,6 +2752,38 @@ fn dispatch_translation_memory_summary_defaults_to_worker_config_path() {
);
}
#[test]
fn dispatch_glossary_summary_reports_missing_database_without_creating_it() {
let temp = tempfile::TempDir::new().unwrap();
let output_root = temp.path().join("output");
let state_dir = temp.path().join("state");
let (queue, _rx) = mpsc::channel::<TaskJob>();
let context = DaemonTaskContext {
registry: TaskRegistry::new(),
queue,
base_config: OfficialUpdateConfig {
output_root: output_root.clone(),
..Default::default()
},
translation_worker_config: TranslationWorkerConfig::default(),
sync_lock: Arc::new(Mutex::new(())),
restart_controller: test_restart_controller,
};
let envelope = dispatch_rpc_method(
&rpc_request("translation.glossary.summary", None),
&state_dir,
&new_daemon_control(),
&context,
"req-glossary-summary-1".to_string(),
);
let value = serde_json::to_value(envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["available"], false);
assert_eq!(value["data"]["reason"], "database_missing");
assert!(!output_root.join("glossary.sqlite").exists());
}
#[test]
fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
let temp = tempfile::TempDir::new().unwrap();
@@ -67,6 +67,7 @@ download_concurrency = 8
provider = 'mock'
fixture = ''
translation_memory_path = ''
glossary_path = ''
concurrency = 8
max_attempts = 3
lease_seconds = 300
@@ -150,6 +151,7 @@ struct TranslationWorkerSection {
provider: Option<String>,
fixture: Option<PathBuf>,
translation_memory_path: Option<PathBuf>,
glossary_path: Option<PathBuf>,
concurrency: Option<usize>,
max_attempts: Option<u32>,
lease_seconds: Option<u64>,
@@ -367,6 +369,9 @@ impl BatConfigFile {
if let Some(value) = self.translation.worker.translation_memory_path.as_ref() {
options.translation_memory_path = Some(value.clone());
}
if let Some(value) = self.translation.worker.glossary_path.as_ref() {
options.glossary_path = Some(value.clone());
}
if let Some(value) = self.translation.worker.concurrency {
options.worker_concurrency = value;
}
@@ -591,6 +596,10 @@ impl BatConfigFile {
line_number,
)?;
}
(SectionPath::TranslationWorker, "glossary_path") => {
self.translation.worker.glossary_path =
parse_optional_path(value, "translation.worker.glossary_path", line_number)?;
}
(SectionPath::TranslationWorker, "concurrency") => {
self.translation.worker.concurrency = Some(parse_translation_worker_concurrency(
&parse_scalar_text(value, "translation.worker.concurrency", line_number)?,
@@ -0,0 +1,649 @@
use super::report_output::print_json_value;
use super::*;
use bat_core::domain::{
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossaryTermDraft,
GlossaryTermSnapshot, TranslationMemoryContext,
};
use bat_infrastructure::{SqliteGlossaryRepository, GLOSSARY_SCHEMA_VERSION};
use std::collections::BTreeMap;
pub(super) fn run_glossary_command(options: &CliOptions) -> anyhow::Result<()> {
let method = glossary_method(options.command)?;
if daemon_rpc_available(&options.state_dir)
&& options.resource_root.is_none()
&& !options.output_explicit
{
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = daemon_rpc_call(&options.state_dir, method, glossary_cli_params(options)?)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let path = glossary_cli_path(options)?;
let report =
match options.command {
CliCommand::GlossarySummary => build_glossary_summary_report(&path)?,
CliCommand::GlossaryQuery => build_glossary_query_report(
&path,
options.glossary_source_text.as_deref(),
options.glossary_category.as_deref(),
options.glossary_review_status.as_deref(),
options.query_limit,
)?,
CliCommand::GlossaryDiagnose => build_glossary_diagnose_report(
&path,
options.glossary_source_text.as_deref().unwrap_or_default(),
parse_glossary_context(options.glossary_context_json.as_deref())?,
)?,
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
let draft = glossary_term_draft(options)?;
let reviewer = options.glossary_reviewer.as_deref();
build_glossary_mutation_report(
&path,
&draft,
options.command == CliCommand::GlossaryUpdate,
reviewer,
options.glossary_reason.clone(),
)?
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary review 必须指定 --glossary-term-id")
})?;
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary review 必须指定 --glossary-reviewer")
})?;
let status = if options.command == CliCommand::GlossaryApprove {
GlossaryReviewStatus::Approved
} else {
GlossaryReviewStatus::Deprecated
};
build_glossary_review_report(
&path,
term_id,
status,
reviewer,
options.glossary_reason.clone(),
)?
}
_ => unreachable!(),
};
print_json_value(options.output_format, &report)
}
fn glossary_method(command: CliCommand) -> anyhow::Result<&'static str> {
Ok(match command {
CliCommand::GlossarySummary => RPC_METHOD_GLOSSARY_SUMMARY,
CliCommand::GlossaryQuery => RPC_METHOD_GLOSSARY_QUERY,
CliCommand::GlossaryAdd => RPC_METHOD_GLOSSARY_ADD,
CliCommand::GlossaryUpdate => RPC_METHOD_GLOSSARY_UPDATE,
CliCommand::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE,
CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE,
CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE,
_ => return Err(anyhow::anyhow!("不是 Glossary 命令")),
})
}
fn glossary_cli_path(options: &CliOptions) -> anyhow::Result<std::path::PathBuf> {
if let Some(path) = options.glossary_path.as_ref() {
return lexical_absolute(path).map_err(anyhow::Error::msg);
}
let resource_root = options
.resource_root
.as_deref()
.map(lexical_absolute)
.transpose()
.map_err(anyhow::Error::msg)?
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
Ok(SqliteGlossaryRepository::repository_path(&resource_root))
}
fn glossary_cli_params(options: &CliOptions) -> anyhow::Result<Option<serde_json::Value>> {
let mut params = serde_json::Map::new();
if let Some(path) = options.glossary_path.as_ref() {
params.insert("glossary_path".to_string(), serde_json::json!(path));
}
match options.command {
CliCommand::GlossarySummary => {}
CliCommand::GlossaryQuery => {
if let Some(source_text) = options.glossary_source_text.as_deref() {
params.insert("source_text".to_string(), serde_json::json!(source_text));
}
if let Some(category) = options.glossary_category.as_deref() {
params.insert("category".to_string(), serde_json::json!(category));
}
if let Some(status) = options.glossary_review_status.as_deref() {
params.insert("review_status".to_string(), serde_json::json!(status));
}
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
}
CliCommand::GlossaryDiagnose => {
let source_text = options.glossary_source_text.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary diagnose 必须指定 --glossary-source-text")
})?;
params.insert("source_text".to_string(), serde_json::json!(source_text));
params.insert(
"context".to_string(),
serde_json::json!(parse_glossary_context(
options.glossary_context_json.as_deref()
)?),
);
}
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
let draft = glossary_term_draft(options)?;
params.extend(
serde_json::to_value(draft)?
.as_object()
.cloned()
.unwrap_or_default(),
);
if options.command == CliCommand::GlossaryUpdate {
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
if let Some(reason) = options.glossary_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
params.insert(
"term_id".to_string(),
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
);
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
if let Some(reason) = options.glossary_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
_ => unreachable!(),
}
Ok(Some(serde_json::Value::Object(params)))
}
fn glossary_term_draft(options: &CliOptions) -> anyhow::Result<GlossaryTermDraft> {
let term_id = required_option(options.glossary_term_id.as_deref(), "--glossary-term-id")?;
let source_term = required_option(
options.glossary_source_term.as_deref(),
"--glossary-source-term",
)?;
let recommended_translation = required_option(
options.glossary_recommended_translation.as_deref(),
"--glossary-recommended-translation",
)?;
let aliases = parse_string_array(
options.glossary_aliases_json.as_deref(),
"--glossary-aliases-json",
)?;
let allowed_translations = parse_string_array(
options.glossary_allowed_translations_json.as_deref(),
"--glossary-allowed-translations-json",
)?;
let scope = parse_glossary_context(options.glossary_scope_json.as_deref())?;
let source_kind = options.glossary_source_kind.as_deref().unwrap_or("manual");
let source_kind = GlossarySourceKind::parse(source_kind)
.ok_or_else(|| anyhow::anyhow!("Glossary source kind 无效:{source_kind}"))?;
let review_status = options.glossary_review_status.as_deref().unwrap_or("draft");
let review_status = GlossaryReviewStatus::parse(review_status)
.ok_or_else(|| anyhow::anyhow!("Glossary review status 无效:{review_status}"))?;
let now = unix_seconds_now();
Ok(GlossaryTermDraft {
term_id,
definition: GlossaryTermSnapshot {
source_term,
aliases,
recommended_translation,
allowed_translations,
source_language: options.glossary_source_language.clone(),
target_language: options.glossary_target_language.clone(),
category: options.glossary_category.clone(),
priority: options.glossary_priority,
scope,
},
review_status,
source: GlossarySourceRecord {
source_kind,
source_ref: options.glossary_source_ref.clone(),
source_author: options.glossary_source_author.clone(),
source_note: options.glossary_source_note.clone(),
observed_unix_seconds: now,
},
})
}
fn required_option(value: Option<&str>, label: &str) -> anyhow::Result<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("Glossary 必须指定 {label}"))
}
fn parse_string_array(value: Option<&str>, label: &str) -> anyhow::Result<Vec<String>> {
let Some(value) = value else {
return Ok(Vec::new());
};
serde_json::from_str(value)
.map_err(|error| anyhow::anyhow!("{label} 必须是 JSON string array{error}"))
}
fn parse_glossary_context(value: Option<&str>) -> anyhow::Result<TranslationMemoryContext> {
let Some(value) = value else {
return Ok(BTreeMap::new());
};
serde_json::from_str(value)
.map_err(|error| anyhow::anyhow!("Glossary context 必须是 JSON object{error}"))
}
pub(super) fn build_glossary_summary_report(
path: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let summary = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.summary()
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"summary": summary,
}))
}
pub(super) fn build_glossary_query_report(
path: &std::path::Path,
source_text: Option<&str>,
category: Option<&str>,
review_status: Option<&str>,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let status = review_status
.map(|value| {
GlossaryReviewStatus::parse(value)
.ok_or_else(|| anyhow::anyhow!("Glossary review_status 无效"))
})
.transpose()?;
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"source_text": source_text,
"terms": [],
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let terms = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.query(source_text, category, status, limit)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"source_text": source_text,
"terms": terms,
}))
}
pub(super) fn build_glossary_diagnose_report(
path: &std::path::Path,
source_text: &str,
context: TranslationMemoryContext,
) -> anyhow::Result<serde_json::Value> {
if source_text.trim().is_empty() {
return Err(anyhow::anyhow!("Glossary diagnose 的 source_text 不能为空"));
}
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"source_text": source_text,
"context": context,
"evaluation": {
"constraints": [],
"diagnostics": [],
"blocked": false
},
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let evaluation = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.diagnose(source_text, &context)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"source_text": source_text,
"context": context,
"evaluation": evaluation,
}))
}
pub(super) fn build_glossary_mutation_report(
path: &std::path::Path,
draft: &GlossaryTermDraft,
update: bool,
reviewer: Option<&str>,
reason: Option<String>,
) -> anyhow::Result<serde_json::Value> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let term = runtime.block_on(async {
let repository = SqliteGlossaryRepository::new(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let result = if update {
repository
.update(
draft.clone(),
reviewer.ok_or_else(|| anyhow::anyhow!("Glossary update 需要 reviewer"))?,
reason,
)
.await
} else {
repository.add(draft.clone()).await
};
result.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"term": term,
}))
}
pub(super) fn build_glossary_review_report(
path: &std::path::Path,
term_id: &str,
status: GlossaryReviewStatus,
reviewer: &str,
reason: Option<String>,
) -> anyhow::Result<serde_json::Value> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let term = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.review(term_id, status, reviewer, reason)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"term": term,
}))
}
pub(super) fn glossary_rpc_envelope(
request_id: String,
result: Result<serde_json::Value, ApiError>,
) -> RpcEnvelope {
match result {
Ok(data) => rpc_envelope_ok(request_id, "ok", data),
Err(error) => rpc_envelope_error(request_id, error),
}
}
pub(super) fn glossary_summary_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
params,
RPC_METHOD_GLOSSARY_SUMMARY,
)?;
build_glossary_summary_report(&path)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_SUMMARY, error))
}
pub(super) fn glossary_query_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_QUERY)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
let category = glossary_string(&params, "category", RPC_METHOD_GLOSSARY_QUERY)?;
let review_status = glossary_string(&params, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
let limit = glossary_limit(&params, RPC_METHOD_GLOSSARY_QUERY)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_QUERY,
)?;
build_glossary_query_report(&path, source_text, category, review_status, limit)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_QUERY, error))
}
pub(super) fn glossary_diagnose_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
let context = glossary_context(&params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_DIAGNOSE,
)?;
build_glossary_diagnose_report(&path, source_text, context)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DIAGNOSE, error))
}
pub(super) fn glossary_mutation_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
update: bool,
) -> Result<serde_json::Value, ApiError> {
let method = if update {
RPC_METHOD_GLOSSARY_UPDATE
} else {
RPC_METHOD_GLOSSARY_ADD
};
let params = glossary_params(params, method)?;
let draft: GlossaryTermDraft =
serde_json::from_value(serde_json::Value::Object(params.clone())).map_err(|error| {
glossary_invalid(method, format!("Glossary term 参数无效:{error}"))
})?;
let reviewer = glossary_string(&params, "reviewer", method)?;
if update && reviewer.is_none() {
return Err(glossary_invalid(method, "update 缺少 reviewer"));
}
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_mutation_report(&path, &draft, update, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
pub(super) fn glossary_review_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
status: GlossaryReviewStatus,
) -> Result<serde_json::Value, ApiError> {
let method = if status == GlossaryReviewStatus::Approved {
RPC_METHOD_GLOSSARY_APPROVE
} else {
RPC_METHOD_GLOSSARY_DEPRECATE
};
let params = glossary_params(params, method)?;
let term_id = glossary_string(&params, "term_id", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
let reviewer = glossary_string(&params, "reviewer", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_review_report(&path, term_id, status, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
fn glossary_rpc_path(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<std::path::PathBuf, ApiError> {
if let Some(path) = params
.and_then(|value| value.get("glossary_path"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return lexical_absolute(Path::new(path))
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
}
if let Some(path) = default_path {
return lexical_absolute(path)
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
}
let (_, version_state) = read_daemon_resource_state(state_dir)
.map_err(|error| glossary_internal_error(method, error))?;
if let Some(record) = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
{
return Ok(SqliteGlossaryRepository::repository_path(
&record.resource_root,
));
}
Ok(SqliteGlossaryRepository::repository_path(output_root))
}
fn glossary_params(
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<serde_json::Map<String, serde_json::Value>, ApiError> {
match params {
None | Some(serde_json::Value::Null) => Ok(serde_json::Map::new()),
Some(serde_json::Value::Object(value)) => Ok(value.clone()),
Some(_) => Err(glossary_invalid(method, "params 必须是 JSON object")),
}
}
fn glossary_string<'a>(
params: &'a serde_json::Map<String, serde_json::Value>,
key: &str,
method: &'static str,
) -> Result<Option<&'a str>, ApiError> {
let Some(value) = params.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| glossary_invalid(method, format!("{key} 必须是非空字符串")))
.map(Some)
}
fn glossary_context(
params: &serde_json::Map<String, serde_json::Value>,
method: &'static str,
) -> Result<TranslationMemoryContext, ApiError> {
let Some(value) = params
.get("context")
.or_else(|| params.get("source_context"))
else {
return Ok(BTreeMap::new());
};
serde_json::from_value(value.clone()).map_err(|error| {
glossary_invalid(method, format!("context 必须是 JSON string map{error}"))
})
}
fn glossary_limit(
params: &serde_json::Map<String, serde_json::Value>,
method: &'static str,
) -> Result<usize, ApiError> {
let limit = params
.get("limit")
.and_then(serde_json::Value::as_u64)
.unwrap_or(100);
let limit = usize::try_from(limit)
.map_err(|error| glossary_invalid(method, format!("limit 无效:{error}")))?;
if !(1..=1000).contains(&limit) {
return Err(glossary_invalid(method, "limit 必须在 1..=1000 范围内"));
}
Ok(limit)
}
fn glossary_invalid(method: &'static str, message: impl Into<String>) -> ApiError {
ApiError::new(ErrorCode::RPC_INVALID_PARAMS, method, message.into())
}
fn glossary_internal_error(method: &'static str, error: anyhow::Error) -> ApiError {
ApiError::new(ErrorCode::INTERNAL, method, error.to_string())
}
@@ -115,9 +115,15 @@ impl HumanReport for bat_infrastructure::TranslationWorkerReport {
print_field("TM 可用", format_bool(self.translation_memory_available));
print_field("TM 命中 TextUnit", self.translation_memory_hit_count);
print_field("Provider TextUnit", self.provider_unit_count);
print_path_field("Glossary", &self.glossary_path);
print_field("Glossary 可用", format_bool(self.glossary_available));
print_field("Glossary blocking TextUnit", self.glossary_blocked_count);
for failure in &self.translation_memory_failures {
println!(" - TM: {failure}");
}
for failure in &self.glossary_failures {
println!(" - Glossary: {failure}");
}
for failure in &self.failures {
println!(
" - {} [{}] retryable={} {}",
@@ -345,6 +345,10 @@ Commands:
i18n handoff Query current translation handoff
i18n status Show localized release status for current official release
i18n task update Update one provider worker task status
i18n glossary summary/query Show project Glossary terms and review counts
i18n glossary add/update Add or replace one Glossary term definition
i18n glossary approve/deprecate Review one Glossary term
i18n glossary diagnose Run deterministic Glossary QA for one TextUnit source
i18n publish Publish a localized release from a workbench or worker results
i18n rollback Roll back the current localized release
i18n schedule Manage translation schedules
@@ -384,6 +388,7 @@ Examples:
{binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1
{binary} i18n proofread --json
{binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s
{binary} i18n glossary diagnose --glossary-source-text Sensei --json
{binary} i18n tasks --json
{binary} i18n handoff --json
{binary} i18n status --json
@@ -433,6 +438,15 @@ Sync:
--provider-run-id <ID> Provider run ID for i18n task update
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
--translation-fixture <PATH> Mock/provider fixture for i18n worker run
--glossary-path <PATH> Project Glossary SQLite path
--glossary-term-id <ID> Glossary term ID for add/update/review
--glossary-source-term <TEXT> Source spelling for a Glossary term
--glossary-recommended-translation <TEXT> Recommended target translation
--glossary-source-text <TEXT> Source TextUnit text for Glossary query/diagnose
--glossary-context-json <JSON> TextUnit context for Glossary diagnose
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews
--glossary-reason <TEXT> Reason for Glossary review or override
--glossary-provenance <TEXT> Provenance for an explicit Glossary override
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
--worker-max-attempts <N> Maximum claims per translation task
--worker-lease-seconds <N> Lease seconds for one claimed task
@@ -1,6 +1,6 @@
use super::report_output::print_json_value;
use super::*;
use bat_core::domain::TranslationMemoryContext;
use bat_core::domain::{GlossaryOverride, TranslationMemoryContext};
use bat_core::repositories::TranslationMemoryRepository;
use std::collections::BTreeMap;
@@ -9,6 +9,8 @@ struct TranslationTaskResultUpdateParam {
unit_id: String,
source_text: String,
translated_text: String,
#[serde(default)]
glossary_override: Option<GlossaryOverride>,
}
pub(super) fn build_translation_tasks_report(
@@ -170,6 +172,7 @@ pub(super) fn build_translation_handoff_report(
pub(super) fn update_translation_task_status_report(
state_dir: &Path,
params: Option<&serde_json::Value>,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<serde_json::Value> {
let task_id = rpc_string_param(params, "task_id")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
@@ -229,6 +232,22 @@ pub(super) fn update_translation_task_status_report(
.find(task_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let glossary_path = configured_glossary_path
.map(PathBuf::from)
.unwrap_or_else(|| {
bat_infrastructure::SqliteGlossaryRepository::repository_path(
&current.resource_root,
)
});
let glossary = if std::fs::symlink_metadata(&glossary_path).is_ok() {
Some(
bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
.await
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))?,
)
} else {
None
};
let results = build_manual_translation_results(
&current_task,
index,
@@ -236,7 +255,9 @@ pub(super) fn update_translation_task_status_report(
&result_provider,
&result_provider_run_id,
result_timestamp,
)?;
glossary.as_ref(),
)
.await?;
repository
.update_status_with_results(
task_id,
@@ -291,13 +312,14 @@ fn translation_task_result_params(
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
}
fn build_manual_translation_results(
async fn build_manual_translation_results(
task: &bat_infrastructure::PersistedTranslationTask,
index: &bat_infrastructure::OfficialTextUnitIndex,
params: &[TranslationTaskResultUpdateParam],
provider: &str,
provider_run_id: &str,
translated_unix_seconds: u64,
glossary: Option<&bat_infrastructure::SqliteGlossaryRepository>,
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
let index_by_id = index
.units
@@ -332,6 +354,53 @@ fn build_manual_translation_results(
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
));
}
let glossary_qa = if let Some(glossary) = glossary {
let context = bat_infrastructure::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,
);
Some(
glossary
.diagnose(&unit.source_text, &context)
.await
.map_err(|error| anyhow::anyhow!("Glossary QA 失败:{error}"))?
.check_translation(&param.translated_text),
)
} else {
None
};
if let Some(qa) = glossary_qa.as_ref().filter(|qa| qa.status.is_blocked()) {
let Some(override_record) = param.glossary_override.as_ref() else {
return Err(anyhow::anyhow!(
"TextUnit {} 的 Glossary QA blocked;必须提供 glossary_override",
unit_id
));
};
if override_record.reviewer.trim().is_empty()
|| override_record.reason.trim().is_empty()
|| override_record.provenance.trim().is_empty()
|| override_record.confirmed_unix_seconds == 0
{
return Err(anyhow::anyhow!(
"TextUnit {} 的 glossary_override 不完整或 confirmed_unix_seconds 无效",
unit_id
));
}
let _ = qa;
} else if param.glossary_override.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
unit_id
));
}
results.push(bat_infrastructure::TranslationTaskUnitResult {
unit_id: unit_id.to_string(),
source_text: param.source_text.clone(),
@@ -341,6 +410,8 @@ fn build_manual_translation_results(
provider: provider.to_string(),
provider_run_id: provider_run_id.to_string(),
translated_unix_seconds,
glossary_qa,
glossary_override: param.glossary_override.clone(),
});
}
Ok(results)
@@ -104,7 +104,12 @@ pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<(
.ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
let (resource_root, release_id) = current_official_release(options)?;
let workbench = read_translation_workbench(path)?;
let validation = validate_translation_workbench(&resource_root, &release_id, &workbench)?;
let validation = validate_translation_workbench_with_glossary_path(
&resource_root,
&release_id,
&workbench,
options.glossary_path.as_deref(),
)?;
let data = serde_json::json!({
"official_release_id": release_id,
"resource_root": resource_root,
@@ -141,7 +146,50 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
.translation_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?;
let entry = set_translation(path, entry_id, text)?;
let glossary_override = match (
options.glossary_reviewer.as_deref(),
options.glossary_reason.as_deref(),
options.glossary_override_provenance.as_deref(),
) {
(None, None, None) => None,
(Some(reviewer), Some(reason), Some(provenance)) => Some(
bat_core::domain::GlossaryOverride {
reviewer: reviewer.to_string(),
reason: reason.to_string(),
provenance: provenance.to_string(),
confirmed_unix_seconds: unix_seconds_now(),
},
),
_ => {
return Err(anyhow::anyhow!(
"Glossary override 必须同时指定 --glossary-reviewer、--glossary-reason 和 --glossary-provenance"
))
}
};
let workbench = read_translation_workbench(path)?;
let glossary_path = options.glossary_path.clone().unwrap_or_else(|| {
bat_infrastructure::SqliteGlossaryRepository::repository_path(
&workbench.official_resource_root,
)
});
let entry = if std::fs::symlink_metadata(&glossary_path).is_ok() {
let (resource_root, _) = current_official_release(options)?;
set_translation_checked_with_glossary_path(
&resource_root,
path,
entry_id,
text,
glossary_override,
options.glossary_path.as_deref(),
)?
} else {
if glossary_override.is_some() {
return Err(anyhow::anyhow!(
"当前项目没有 Glossary 数据库,不能提交 Glossary override"
));
}
set_translation(path, entry_id, text)?
};
let data = serde_json::json!({
"translation_file": path,
"entry": entry,
@@ -232,6 +280,7 @@ pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Resul
let report = update_translation_task_status_report(
&options.state_dir,
Some(&serde_json::Value::Object(params)),
options.glossary_path.as_deref(),
)?;
print_json_value(options.output_format, &report)
}
@@ -316,6 +365,12 @@ pub(super) fn publish_localized_report(
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
));
}
validate_translation_workbench_with_glossary_path(
&resource_root,
&official_release_id,
&workbench,
options.glossary_path.as_deref(),
)?;
let operations = localized_patch_operations(&resource_root, &workbench)?;
let localized_release_id = options.localized_release_id.clone().or_else(|| {
options
+757
View File
@@ -0,0 +1,757 @@
//! Project-level Glossary V1 SQLite repository.
use crate::path_security::{
ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE,
};
use async_trait::async_trait;
use bat_core::domain::{
evaluate_glossary, validate_glossary_draft, GlossaryEvaluation, GlossaryHistoryRecord,
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossarySummary, GlossaryTerm,
GlossaryTermDraft, GlossaryTermSnapshot, TranslationMemoryContext,
};
use bat_core::repositories::GlossaryRepository;
use bat_core::{Error, Result};
use serde::de::DeserializeOwned;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Glossary SQLite schema version.
pub const GLOSSARY_SCHEMA_VERSION: u32 = 1;
/// Schema migration component.
pub const GLOSSARY_SCHEMA_COMPONENT: &str = "glossary";
/// Default project-level glossary file.
pub const GLOSSARY_REPOSITORY_FILE: &str = "glossary.sqlite";
/// SQLite-backed project Glossary repository.
#[derive(Debug, Clone)]
pub struct SqliteGlossaryRepository {
pool: SqlitePool,
}
impl SqliteGlossaryRepository {
/// Creates or opens a glossary database.
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with(path.as_ref(), true).await
}
/// Opens an existing glossary database without creating it.
pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with(path.as_ref(), false).await
}
/// Returns the project-level glossary path for an official release root.
pub fn repository_path(resource_root: &Path) -> PathBuf {
if resource_root
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name == "versions")
{
if let Some(output_root) = resource_root.parent().and_then(Path::parent) {
return output_root.join(GLOSSARY_REPOSITORY_FILE);
}
}
resource_root.join(GLOSSARY_REPOSITORY_FILE)
}
async fn open_with(path: &Path, create_if_missing: bool) -> Result<Self> {
let absolute = lexical_absolute(path).map_err(Error::InvalidArgument)?;
let parent = absolute.parent().ok_or_else(|| {
Error::InvalidArgument(format!("Glossary 数据库缺少父目录:{}", absolute.display()))
})?;
ensure_safe_directory_path(parent, "Glossary 数据库").map_err(Error::InvalidArgument)?;
if create_if_missing {
tokio::fs::create_dir_all(parent).await?;
ensure_safe_directory_path(parent, "Glossary 数据库")
.map_err(Error::InvalidArgument)?;
} else if !absolute.is_file() {
return Err(Error::NotFound(absolute.display().to_string()));
}
if let Ok(metadata) = std::fs::symlink_metadata(&absolute) {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(Error::InvalidArgument(format!(
"Glossary 数据库必须是普通文件:{}",
absolute.display()
)));
}
}
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", absolute.display()))
.map_err(|error| Error::Other(error.into()))?
.create_if_missing(create_if_missing)
.journal_mode(SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(30));
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.map_err(db_error)?;
if create_if_missing {
set_file_mode(&absolute, STATE_FILE_MODE, "Glossary 数据库")
.map_err(Error::InvalidArgument)?;
}
let repository = Self { pool };
repository.init_schema().await?;
Ok(repository)
}
async fn init_schema(&self) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_migrations (
component TEXT PRIMARY KEY NOT NULL,
version INTEGER NOT NULL CHECK(version >= 1)
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS glossary_terms (
term_id TEXT PRIMARY KEY NOT NULL,
source_term TEXT NOT NULL,
aliases_json TEXT NOT NULL,
recommended_translation TEXT NOT NULL,
allowed_translations_json TEXT NOT NULL,
source_language TEXT,
target_language TEXT,
category TEXT,
priority INTEGER NOT NULL,
scope_json TEXT NOT NULL,
review_status TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_ref TEXT,
source_author TEXT,
source_note TEXT,
source_observed_unix_seconds INTEGER NOT NULL,
created_unix_seconds INTEGER NOT NULL,
updated_unix_seconds INTEGER NOT NULL,
CHECK(length(term_id) > 0),
CHECK(length(source_term) > 0),
CHECK(length(recommended_translation) > 0),
CHECK(review_status IN ('draft', 'approved', 'deprecated', 'rejected')),
CHECK(source_kind IN ('manual', 'imported'))
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
ensure_column(
&self.pool,
"glossary_terms",
"source_observed_unix_seconds",
"INTEGER NOT NULL DEFAULT 1",
)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS glossary_term_history (
history_id TEXT PRIMARY KEY NOT NULL,
term_id TEXT NOT NULL,
action TEXT NOT NULL,
reviewer TEXT,
reason TEXT,
source_json TEXT NOT NULL,
review_status TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
observed_unix_seconds INTEGER NOT NULL,
FOREIGN KEY(term_id) REFERENCES glossary_terms(term_id)
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_status
ON glossary_terms(review_status, priority DESC, term_id)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_source_term
ON glossary_terms(source_term)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
let current: Option<i64> =
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
.bind(GLOSSARY_SCHEMA_COMPONENT)
.fetch_optional(&self.pool)
.await
.map_err(db_error)?;
if current.is_some_and(|version| version > i64::from(GLOSSARY_SCHEMA_VERSION)) {
return Err(Error::InvalidArgument(format!(
"不支持的 Glossary schema 版本:{}",
current.unwrap_or_default()
)));
}
sqlx::query(
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
)
.bind(GLOSSARY_SCHEMA_COMPONENT)
.bind(i64::from(GLOSSARY_SCHEMA_VERSION))
.execute(&self.pool)
.await
.map_err(db_error)?;
Ok(())
}
/// Returns one term with complete history.
pub async fn find(&self, term_id: &str) -> Result<GlossaryTerm> {
let row = sqlx::query(
"SELECT term_id, source_term, aliases_json, recommended_translation,
allowed_translations_json, source_language, target_language, category,
priority, scope_json, review_status, source_kind, source_ref,
source_author, source_note, source_observed_unix_seconds,
created_unix_seconds, updated_unix_seconds
FROM glossary_terms WHERE term_id = ?1",
)
.bind(term_id)
.fetch_optional(&self.pool)
.await
.map_err(db_error)?
.ok_or_else(|| Error::NotFound(term_id.to_string()))?;
let mut term = row_to_term(row)?;
let history_rows = sqlx::query(
"SELECT history_id, action, reviewer, reason, source_json, review_status,
snapshot_json, observed_unix_seconds
FROM glossary_term_history WHERE term_id = ?1
ORDER BY observed_unix_seconds ASC, history_id ASC",
)
.bind(term_id)
.fetch_all(&self.pool)
.await
.map_err(db_error)?;
term.history = history_rows
.into_iter()
.map(row_to_history)
.collect::<Result<Vec<_>>>()?;
Ok(term)
}
/// Queries terms, including non-approved terms for review.
pub async fn query(
&self,
source_text: Option<&str>,
category: Option<&str>,
review_status: Option<GlossaryReviewStatus>,
limit: usize,
) -> Result<Vec<GlossaryTerm>> {
if !(1..=1000).contains(&limit) {
return Err(Error::InvalidArgument(
"Glossary query limit 必须在 1..=1000 范围内".to_string(),
));
}
let rows = sqlx::query(
"SELECT term_id FROM glossary_terms
WHERE (?1 IS NULL OR category = ?1)
AND (?2 IS NULL OR review_status = ?2)
ORDER BY priority DESC, term_id ASC",
)
.bind(category)
.bind(review_status.map(|value| value.as_str()))
.fetch_all(&self.pool)
.await
.map_err(db_error)?;
let mut terms = Vec::new();
for row in rows {
let term_id: String = row.try_get("term_id").map_err(db_error)?;
let term = self.find(&term_id).await?;
if source_text.is_none_or(|source| {
let mut spellings = vec![term.definition.source_term.as_str()];
spellings.extend(term.definition.aliases.iter().map(String::as_str));
spellings
.into_iter()
.any(|spelling| source.contains(spelling))
}) {
terms.push(term);
if terms.len() >= limit {
break;
}
}
}
Ok(terms)
}
/// Returns review-state counts.
pub async fn summary(&self) -> Result<GlossarySummary> {
let row = sqlx::query(
"SELECT COUNT(*) AS term_count,
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count,
SUM(CASE WHEN review_status = 'draft' THEN 1 ELSE 0 END) AS draft_count,
SUM(CASE WHEN review_status = 'deprecated' THEN 1 ELSE 0 END) AS deprecated_count,
SUM(CASE WHEN review_status = 'rejected' THEN 1 ELSE 0 END) AS rejected_count
FROM glossary_terms",
)
.fetch_one(&self.pool)
.await
.map_err(db_error)?;
Ok(GlossarySummary {
schema_version: GLOSSARY_SCHEMA_VERSION,
term_count: row.try_get::<i64, _>("term_count").map_err(db_error)? as u64,
approved_count: row.try_get::<i64, _>("approved_count").map_err(db_error)? as u64,
draft_count: row.try_get::<i64, _>("draft_count").map_err(db_error)? as u64,
deprecated_count: row
.try_get::<i64, _>("deprecated_count")
.map_err(db_error)? as u64,
rejected_count: row.try_get::<i64, _>("rejected_count").map_err(db_error)? as u64,
})
}
/// Adds a term and records its source snapshot.
pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?;
let now = draft.source.observed_unix_seconds;
let term = term_from_draft(&draft, now, now);
let mut transaction = self.pool.begin().await.map_err(db_error)?;
let existing: Option<String> =
sqlx::query_scalar("SELECT term_id FROM glossary_terms WHERE term_id = ?1")
.bind(&draft.term_id)
.fetch_optional(&mut *transaction)
.await
.map_err(db_error)?;
if existing.is_some() {
return Err(Error::InvalidArgument(format!(
"Glossary term_id 已存在:{}",
draft.term_id
)));
}
insert_term(&mut transaction, &term).await?;
insert_history(&mut transaction, &term, "created", None, None, now).await?;
transaction.commit().await.map_err(db_error)?;
Ok(term)
}
/// Replaces a term definition and records the previous source history.
pub async fn update(
&self,
draft: GlossaryTermDraft,
reviewer: &str,
reason: Option<String>,
) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?;
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary update reviewer 不能为空".to_string(),
));
}
let current = self.find(&draft.term_id).await?;
let now = draft.source.observed_unix_seconds;
let term = term_from_draft(&draft, current.created_unix_seconds, now);
let mut transaction = self.pool.begin().await.map_err(db_error)?;
update_term(&mut transaction, &term).await?;
insert_history(
&mut transaction,
&term,
"updated",
Some(reviewer.trim()),
reason.as_deref(),
now,
)
.await?;
transaction.commit().await.map_err(db_error)?;
self.find(&draft.term_id).await
}
/// Changes review state and records a source/review history entry.
pub async fn review(
&self,
term_id: &str,
status: GlossaryReviewStatus,
reviewer: &str,
reason: Option<String>,
) -> Result<GlossaryTerm> {
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary reviewer 不能为空".to_string(),
));
}
if !matches!(
status,
GlossaryReviewStatus::Approved
| GlossaryReviewStatus::Deprecated
| GlossaryReviewStatus::Rejected
) {
return Err(Error::InvalidArgument(
"Glossary review 只允许 approved、deprecated 或 rejected".to_string(),
));
}
let mut term = self.find(term_id).await?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
term.review_status = status;
term.updated_unix_seconds = now;
let mut transaction = self.pool.begin().await.map_err(db_error)?;
sqlx::query(
"UPDATE glossary_terms SET review_status = ?2, updated_unix_seconds = ?3
WHERE term_id = ?1",
)
.bind(term_id)
.bind(status.as_str())
.bind(i64::try_from(now).unwrap_or(i64::MAX))
.execute(&mut *transaction)
.await
.map_err(db_error)?;
insert_history(
&mut transaction,
&term,
status.as_str(),
Some(reviewer.trim()),
reason.as_deref(),
now,
)
.await?;
transaction.commit().await.map_err(db_error)?;
self.find(term_id).await
}
/// Evaluates approved terms for one TextUnit.
pub async fn diagnose(
&self,
source_text: &str,
context: &TranslationMemoryContext,
) -> Result<GlossaryEvaluation> {
let terms = self
.query(None, None, Some(GlossaryReviewStatus::Approved), 1000)
.await?;
Ok(evaluate_glossary(&terms, source_text, context))
}
}
#[async_trait]
impl GlossaryRepository for SqliteGlossaryRepository {
async fn evaluate(
&self,
source_text: &str,
context: &TranslationMemoryContext,
) -> Result<GlossaryEvaluation> {
self.diagnose(source_text, context).await
}
}
fn term_from_draft(draft: &GlossaryTermDraft, created: u64, updated: u64) -> GlossaryTerm {
GlossaryTerm {
term_id: draft.term_id.clone(),
definition: draft.definition.clone(),
review_status: draft.review_status,
source: draft.source.clone(),
history: Vec::new(),
created_unix_seconds: created,
updated_unix_seconds: updated,
}
}
async fn insert_term(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
term: &GlossaryTerm,
) -> Result<()> {
sqlx::query(
"INSERT INTO glossary_terms (
term_id, source_term, aliases_json, recommended_translation,
allowed_translations_json, source_language, target_language, category,
priority, scope_json, review_status, source_kind, source_ref,
source_author, source_note, source_observed_unix_seconds,
created_unix_seconds, updated_unix_seconds
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
)
.bind(&term.term_id)
.bind(&term.definition.source_term)
.bind(json(&term.definition.aliases)?)
.bind(&term.definition.recommended_translation)
.bind(json(&term.definition.allowed_translations)?)
.bind(&term.definition.source_language)
.bind(&term.definition.target_language)
.bind(&term.definition.category)
.bind(term.definition.priority)
.bind(json(&term.definition.scope)?)
.bind(term.review_status.as_str())
.bind(term.source.source_kind.as_str())
.bind(&term.source.source_ref)
.bind(&term.source.source_author)
.bind(&term.source.source_note)
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
.bind(i64::try_from(term.created_unix_seconds).unwrap_or(i64::MAX))
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
.execute(&mut **transaction)
.await
.map_err(db_error)?;
Ok(())
}
async fn update_term(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
term: &GlossaryTerm,
) -> Result<()> {
sqlx::query(
"UPDATE glossary_terms SET source_term = ?2, aliases_json = ?3,
recommended_translation = ?4, allowed_translations_json = ?5,
source_language = ?6, target_language = ?7, category = ?8,
priority = ?9, scope_json = ?10, review_status = ?11, source_kind = ?12,
source_ref = ?13, source_author = ?14, source_note = ?15,
source_observed_unix_seconds = ?16, updated_unix_seconds = ?17 WHERE term_id = ?1",
)
.bind(&term.term_id)
.bind(&term.definition.source_term)
.bind(json(&term.definition.aliases)?)
.bind(&term.definition.recommended_translation)
.bind(json(&term.definition.allowed_translations)?)
.bind(&term.definition.source_language)
.bind(&term.definition.target_language)
.bind(&term.definition.category)
.bind(term.definition.priority)
.bind(json(&term.definition.scope)?)
.bind(term.review_status.as_str())
.bind(term.source.source_kind.as_str())
.bind(&term.source.source_ref)
.bind(&term.source.source_author)
.bind(&term.source.source_note)
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
.execute(&mut **transaction)
.await
.map_err(db_error)?;
Ok(())
}
async fn insert_history(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
term: &GlossaryTerm,
action: &str,
reviewer: Option<&str>,
reason: Option<&str>,
observed: u64,
) -> Result<()> {
let source_json = json(&term.source)?;
let snapshot_json = json(&term.definition)?;
let mut history_hasher = blake3::Hasher::new();
for value in [
term.term_id.as_str(),
action,
reviewer.unwrap_or_default(),
reason.unwrap_or_default(),
source_json.as_str(),
snapshot_json.as_str(),
] {
history_hasher.update(value.as_bytes());
history_hasher.update(&[0]);
}
history_hasher.update(&observed.to_le_bytes());
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
history_hasher.update(&nonce.to_le_bytes());
let history_id = format!("glh-{}", history_hasher.finalize().to_hex());
sqlx::query(
"INSERT INTO glossary_term_history (
history_id, term_id, action, reviewer, reason, source_json,
review_status, snapshot_json, observed_unix_seconds
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)
.bind(history_id)
.bind(&term.term_id)
.bind(action)
.bind(reviewer)
.bind(reason.filter(|value| !value.trim().is_empty()))
.bind(source_json)
.bind(term.review_status.as_str())
.bind(snapshot_json)
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
.execute(&mut **transaction)
.await
.map_err(db_error)?;
Ok(())
}
fn row_to_term(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryTerm> {
Ok(GlossaryTerm {
term_id: row.try_get("term_id").map_err(db_error)?,
definition: GlossaryTermSnapshot {
source_term: row.try_get("source_term").map_err(db_error)?,
aliases: parse_json(row.try_get("aliases_json").map_err(db_error)?)?,
recommended_translation: row.try_get("recommended_translation").map_err(db_error)?,
allowed_translations: parse_json(
row.try_get("allowed_translations_json").map_err(db_error)?,
)?,
source_language: row.try_get("source_language").map_err(db_error)?,
target_language: row.try_get("target_language").map_err(db_error)?,
category: row.try_get("category").map_err(db_error)?,
priority: row.try_get("priority").map_err(db_error)?,
scope: parse_json(row.try_get("scope_json").map_err(db_error)?)?,
},
review_status: parse_review_status(
row.try_get::<String, _>("review_status")
.map_err(db_error)?
.as_str(),
)?,
source: GlossarySourceRecord {
source_kind: parse_source_kind(
row.try_get::<String, _>("source_kind")
.map_err(db_error)?
.as_str(),
)?,
source_ref: row.try_get("source_ref").map_err(db_error)?,
source_author: row.try_get("source_author").map_err(db_error)?,
source_note: row.try_get("source_note").map_err(db_error)?,
observed_unix_seconds: to_u64(
row.try_get("source_observed_unix_seconds")
.map_err(db_error)?,
"source",
)?,
},
history: Vec::new(),
created_unix_seconds: to_u64(
row.try_get("created_unix_seconds").map_err(db_error)?,
"created",
)?,
updated_unix_seconds: to_u64(
row.try_get("updated_unix_seconds").map_err(db_error)?,
"updated",
)?,
})
}
fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryHistoryRecord> {
Ok(GlossaryHistoryRecord {
history_id: row.try_get("history_id").map_err(db_error)?,
action: row.try_get("action").map_err(db_error)?,
reviewer: row.try_get("reviewer").map_err(db_error)?,
reason: row.try_get("reason").map_err(db_error)?,
source: parse_json(row.try_get("source_json").map_err(db_error)?)?,
review_status: parse_review_status(
row.try_get::<String, _>("review_status")
.map_err(db_error)?
.as_str(),
)?,
snapshot: parse_json(row.try_get("snapshot_json").map_err(db_error)?)?,
observed_unix_seconds: to_u64(
row.try_get("observed_unix_seconds").map_err(db_error)?,
"history",
)?,
})
}
fn parse_json<T: DeserializeOwned>(value: String) -> Result<T> {
serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string()))
}
fn json<T: serde::Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).map_err(|error| Error::Serialization(error.to_string()))
}
fn parse_review_status(value: &str) -> Result<GlossaryReviewStatus> {
GlossaryReviewStatus::parse(value)
.ok_or_else(|| Error::Serialization(format!("未知 Glossary review status{value}")))
}
fn parse_source_kind(value: &str) -> Result<GlossarySourceKind> {
GlossarySourceKind::parse(value)
.ok_or_else(|| Error::Serialization(format!("未知 Glossary source kind{value}")))
}
fn to_u64(value: i64, label: &str) -> Result<u64> {
u64::try_from(value).map_err(|_| Error::Serialization(format!("Glossary {label} 时间无效")))
}
fn db_error(error: sqlx::Error) -> Error {
Error::Other(error.into())
}
async fn ensure_column(
pool: &SqlitePool,
table: &str,
column: &str,
definition: &str,
) -> Result<()> {
let columns = sqlx::query(&format!("PRAGMA table_info({table})"))
.fetch_all(pool)
.await
.map_err(db_error)?;
let exists = columns.iter().any(|row| {
row.try_get::<String, _>("name")
.map(|name| name == column)
.unwrap_or(false)
});
if !exists {
sqlx::query(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {definition}"
))
.execute(pool)
.await
.map_err(db_error)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn draft(status: GlossaryReviewStatus) -> GlossaryTermDraft {
GlossaryTermDraft {
term_id: "term-sensei".to_string(),
definition: GlossaryTermSnapshot {
source_term: "Sensei".to_string(),
aliases: vec!["Teacher".to_string()],
recommended_translation: "老师".to_string(),
allowed_translations: vec!["老师大人".to_string()],
source_language: Some("en".to_string()),
target_language: Some("zh-Hans".to_string()),
category: Some("person".to_string()),
priority: 10,
scope: BTreeMap::new(),
},
review_status: status,
source: GlossarySourceRecord {
source_kind: GlossarySourceKind::Manual,
source_ref: Some("test".to_string()),
source_author: Some("tester".to_string()),
source_note: None,
observed_unix_seconds: 1,
},
}
}
#[tokio::test]
async fn sqlite_glossary_preserves_history_and_only_approved_terms_match() {
let temp = tempfile::TempDir::new().unwrap();
let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite"))
.await
.unwrap();
repository
.add(draft(GlossaryReviewStatus::Draft))
.await
.unwrap();
let before = repository
.diagnose("Sensei", &BTreeMap::new())
.await
.unwrap();
assert!(before.constraints.is_empty());
repository
.review(
"term-sensei",
GlossaryReviewStatus::Approved,
"reviewer",
Some("ok".to_string()),
)
.await
.unwrap();
let after = repository
.diagnose("Sensei", &BTreeMap::new())
.await
.unwrap();
assert_eq!(after.constraints.len(), 1);
let term = repository.find("term-sensei").await.unwrap();
assert_eq!(term.history.len(), 2);
assert_eq!(term.history[1].action, "approved");
let summary = repository.summary().await.unwrap();
assert_eq!(summary.approved_count, 1);
}
}
+10 -3
View File
@@ -13,6 +13,7 @@
pub mod cas;
mod curl_transfer;
pub mod downloader;
pub mod glossary;
pub mod import;
pub mod localized_patch;
pub mod official_changes;
@@ -43,6 +44,10 @@ pub use downloader::{
DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY,
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
};
pub use glossary::{
SqliteGlossaryRepository, GLOSSARY_REPOSITORY_FILE, GLOSSARY_SCHEMA_COMPONENT,
GLOSSARY_SCHEMA_VERSION,
};
pub use import::{
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
ResourceImportService,
@@ -169,9 +174,11 @@ pub use translation_workflow::{
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
export_translation_workbench, get_translation_entry, localized_patch_operations,
localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation,
unset_translation, validate_translation_workbench, write_translation_workbench,
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
set_translation_checked, set_translation_checked_with_glossary_path, unset_translation,
validate_translation_workbench, validate_translation_workbench_with_glossary_path,
write_translation_workbench, RepackOperation, RepackReport, RepackSpec, TranslationWorkbench,
TranslationWorkbenchEntry, TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION,
TRANSLATION_WORKBENCH_VERSION,
};
/// Infrastructure 版本号
+6
View File
@@ -99,6 +99,12 @@ pub struct LocalizedPatchOperationMetadata {
pub translation_memory_record_id: Option<String>,
/// Review state used by the publication input.
pub review_status: String,
/// Deterministic Glossary QA recorded for this translation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
/// Explicit confirmation for a blocking Glossary deviation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
}
/// Configuration for one localized release publication.
+10
View File
@@ -173,6 +173,12 @@ pub struct TranslationTaskUnitResult {
pub provider_run_id: String,
/// Result persistence time.
pub translated_unix_seconds: u64,
/// Deterministic Glossary QA result, when a project Glossary was available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
/// Explicit human confirmation for a blocking Glossary deviation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
}
/// Source of one persisted TextUnit translation result.
@@ -1808,6 +1814,8 @@ mod tests {
provider: "manual".to_string(),
provider_run_id: "manual-run-1".to_string(),
translated_unix_seconds: 321,
glossary_qa: None,
glossary_override: None,
};
let updated = repository
@@ -1927,6 +1935,8 @@ mod tests {
provider: "mock".to_string(),
provider_run_id: second_run.clone(),
translated_unix_seconds: 1,
glossary_qa: None,
glossary_override: None,
};
assert!(repository
+340 -42
View File
@@ -5,6 +5,7 @@
//! 租约和任务结果写入 release 级 `translation-tasks.sqlite`,跨 release 的
//! Translation Memory 写入项目级独立 SQLite 数据库。
use crate::glossary::SqliteGlossaryRepository;
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
use crate::official_textunit_queue::read_textunit_task_queue_at;
use crate::translation_memory::{translation_memory_context, SqliteTranslationMemoryRepository};
@@ -14,9 +15,10 @@ use crate::translation_tasks::{
};
use async_trait::async_trait;
use bat_core::domain::{
TranslationMemoryDraft, TranslationMemorySourceKind, TranslationMemorySourceTrace,
GlossaryConstraint, GlossaryQaReport, TranslationMemoryDraft, TranslationMemorySourceKind,
TranslationMemorySourceTrace,
};
use bat_core::repositories::TranslationMemoryRepository;
use bat_core::repositories::{GlossaryRepository, TranslationMemoryRepository};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
@@ -98,6 +100,9 @@ pub struct TranslationWorkerConfig {
/// Translation Memory SQLite path. `None` uses the output-root default.
#[serde(skip_serializing_if = "Option::is_none")]
pub translation_memory_path: Option<PathBuf>,
/// Project-level Glossary SQLite path. `None` uses the output-root default.
#[serde(skip_serializing_if = "Option::is_none")]
pub glossary_path: Option<PathBuf>,
}
impl Default for TranslationWorkerConfig {
@@ -112,6 +117,7 @@ impl Default for TranslationWorkerConfig {
max_tasks: None,
worker_id: format!("bat-worker-{}", std::process::id()),
translation_memory_path: None,
glossary_path: None,
}
}
}
@@ -180,6 +186,9 @@ pub struct TranslationProviderUnit {
/// 解析器保留的上下文,包括可选 `crowdin_string_id`。
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub context: BTreeMap<String, String>,
/// Approved Glossary constraints for this TextUnit.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub glossary_constraints: Vec<GlossaryConstraint>,
}
/// 一次 provider 批处理请求。
@@ -596,6 +605,14 @@ pub struct TranslationWorkerReport {
pub provider_unit_count: usize,
/// Translation Memory diagnostics that did not invalidate provider work.
pub translation_memory_failures: Vec<String>,
/// Project-level Glossary database path used by this run.
pub glossary_path: PathBuf,
/// Whether a Glossary database was available.
pub glossary_available: bool,
/// TextUnits whose Glossary QA blocked automatic reuse or publication.
pub glossary_blocked_count: usize,
/// Glossary diagnostics that did not abort worker startup.
pub glossary_failures: Vec<String>,
}
/// worker 失败诊断。
@@ -623,6 +640,8 @@ struct WorkerStats {
provider_unit_count: AtomicUsize,
failures: Mutex<Vec<TranslationWorkerFailure>>,
translation_memory_failures: Mutex<Vec<String>>,
glossary_blocked_count: AtomicUsize,
glossary_failures: Mutex<Vec<String>>,
}
struct WorkerTaskContext<'a> {
@@ -635,6 +654,7 @@ struct WorkerTaskContext<'a> {
retry_backoff: Duration,
stats: &'a WorkerStats,
translation_memory: Option<&'a dyn TranslationMemoryRepository>,
glossary: Option<&'a dyn GlossaryRepository>,
}
/// 运行一个 provider worker 轮次。
@@ -715,6 +735,28 @@ async fn run_translation_worker_with_provider_and_cancellation(
)),
),
};
let glossary_path = config
.glossary_path
.clone()
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
let (glossary, glossary_startup_failure) = if std::fs::symlink_metadata(&glossary_path).is_ok()
{
match SqliteGlossaryRepository::open(&glossary_path).await {
Ok(repository) => (Some(Arc::new(repository)), None),
Err(error) => (
None,
Some(format!(
"打开 Glossary 数据库失败 {}{error}",
glossary_path.display()
)),
),
}
} else {
(None, None)
};
if let Some(failure) = glossary_startup_failure.as_deref() {
return Err(anyhow::anyhow!(failure.to_string()));
}
let repository = Arc::new(
SqliteTranslationTaskRepository::new(SqliteTranslationTaskRepository::repository_path(
resource_root,
@@ -754,6 +796,7 @@ async fn run_translation_worker_with_provider_and_cancellation(
let lease_seconds = config.lease_seconds;
let retry_backoff = config.retry_backoff;
let translation_memory = translation_memory.clone();
let glossary = glossary.clone();
let should_cancel = Arc::clone(&should_cancel);
handles.push(tokio::spawn(async move {
loop {
@@ -791,6 +834,9 @@ async fn run_translation_worker_with_provider_and_cancellation(
translation_memory: translation_memory
.as_deref()
.map(|repository| repository as &dyn TranslationMemoryRepository),
glossary: glossary
.as_deref()
.map(|repository| repository as &dyn GlossaryRepository),
},
&task,
)
@@ -842,6 +888,11 @@ async fn run_translation_worker_with_provider_and_cancellation(
.lock()
.map_err(|_| anyhow::anyhow!("读取 Translation Memory 诊断时 mutex poisoned"))?
.clone();
let glossary_failures = stats
.glossary_failures
.lock()
.map_err(|_| anyhow::anyhow!("读取 Glossary 诊断时 mutex poisoned"))?
.clone();
Ok(TranslationWorkerReport {
command: "translation-worker",
status: if failed_count == 0 {
@@ -863,6 +914,10 @@ async fn run_translation_worker_with_provider_and_cancellation(
translation_memory_hit_count: stats.translation_memory_hit_count.load(Ordering::Relaxed),
provider_unit_count: stats.provider_unit_count.load(Ordering::Relaxed),
translation_memory_failures,
glossary_path,
glossary_available: glossary.is_some(),
glossary_blocked_count: stats.glossary_blocked_count.load(Ordering::Relaxed),
glossary_failures,
})
}
@@ -873,35 +928,99 @@ async fn process_claimed_task(
let task_units = task_index_units(task, context.index)?;
let mut results = BTreeMap::new();
let mut provider_units = Vec::new();
let mut glossary_evaluations = BTreeMap::new();
for unit in &task_units {
let source_context = 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 glossary_evaluation = if let Some(glossary) = context.glossary {
match glossary.evaluate(&unit.source_text, &source_context).await {
Ok(evaluation) => evaluation,
Err(error) => {
record_glossary_failure(
context,
format!(
"任务 {} TextUnit {} 查询失败:{}",
task.task.task_id, unit.id, error
),
)?;
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
format!("TextUnit {} 无法完成 Glossary QA;自动翻译已阻止", unit.id),
),
&results,
)
.await?;
return Ok(());
}
}
} else {
bat_core::domain::GlossaryEvaluation {
constraints: Vec::new(),
diagnostics: Vec::new(),
blocked: false,
}
};
if glossary_evaluation.blocked {
context
.stats
.glossary_blocked_count
.fetch_add(1, Ordering::Relaxed);
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
format!(
"TextUnit {} 的 Glossary 存在未解决冲突,必须人工确认后才能继续",
unit.id
),
),
&results,
)
.await?;
return Ok(());
}
glossary_evaluations.insert(unit.id.clone(), glossary_evaluation);
if let Some(translation_memory) = context.translation_memory {
let source_context = 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,
);
match translation_memory
.find_matches(&unit.source_text, &source_context, 1)
.await
{
Ok(matches) => {
if let Some(found) = matches.into_iter().find(|item| item.can_auto_reuse) {
context
.stats
.translation_memory_hit_count
.fetch_add(1, Ordering::Relaxed);
results.insert(
unit.id.clone(),
translation_memory_result(task, unit, &found.entry),
);
continue;
let qa = glossary_evaluations
.get(&unit.id)
.expect("Glossary evaluation inserted before TM lookup")
.check_translation(&found.entry.translated_text);
if qa.status.is_blocked() {
context
.stats
.glossary_blocked_count
.fetch_add(1, Ordering::Relaxed);
} else {
context
.stats
.translation_memory_hit_count
.fetch_add(1, Ordering::Relaxed);
results.insert(
unit.id.clone(),
translation_memory_result(task, unit, &found.entry, qa),
);
continue;
}
}
}
Err(error) => {
@@ -923,7 +1042,7 @@ async fn process_claimed_task(
.stats
.provider_unit_count
.fetch_add(provider_units.len(), Ordering::Relaxed);
let request = match provider_request(task, &provider_units) {
let request = match provider_request(task, &provider_units, &glossary_evaluations) {
Ok(request) => request,
Err(error) => {
record_provider_failure(
@@ -941,23 +1060,48 @@ async fn process_claimed_task(
};
match context.provider.translate(request.clone()).await {
Ok(response) => {
let provider_results =
match validate_provider_response(&request, response, context.provider_name) {
Ok(results) => results,
Err(error) => {
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
error.to_string(),
),
&results,
)
.await?;
return Ok(());
}
};
let provider_results = match validate_provider_response(
&request,
response,
context.provider_name,
&glossary_evaluations,
) {
Ok(results) => results,
Err(error) => {
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
error.to_string(),
),
&results,
)
.await?;
return Ok(());
}
};
if provider_results.iter().any(|result| {
result
.glossary_qa
.as_ref()
.is_some_and(|qa| qa.status.is_blocked())
}) {
for result in &provider_results {
results.insert(result.unit_id.clone(), result.clone());
}
record_provider_failure(
context,
task,
TranslationProviderError::new(
TranslationProviderFailureClass::InvalidRequest,
"provider 译文未通过 Glossary QA;需要人工 override 后才能发布",
),
&results,
)
.await?;
return Ok(());
}
for result in &provider_results {
results.insert(result.unit_id.clone(), result.clone());
if let Some(unit) = provider_units.iter().find(|unit| unit.id == result.unit_id)
@@ -986,6 +1130,13 @@ async fn process_claimed_task(
observed_unix_seconds: unix_seconds_now(),
};
if let Some(translation_memory) = context.translation_memory {
if results
.get(&result.unit_id)
.and_then(|value| value.glossary_qa.as_ref())
.is_some_and(|qa| qa.status.is_blocked())
{
continue;
}
if let Err(error) = translation_memory.upsert_candidate(draft).await {
record_translation_memory_failure(
context,
@@ -1066,6 +1217,7 @@ fn task_index_units<'a>(
fn provider_request(
task: &PersistedTranslationTask,
index_units: &[&OfficialTextUnitIndexUnit],
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
) -> anyhow::Result<TranslationProviderRequest> {
let provider_run_id = task
.provider_run_id
@@ -1079,7 +1231,16 @@ fn provider_request(
archive_entry: task.task.archive_entry.clone(),
units: index_units
.iter()
.map(|unit| provider_unit(task, unit))
.map(|unit| {
provider_unit(
task,
unit,
glossary_evaluations
.get(&unit.id)
.map(|evaluation| evaluation.constraints.clone())
.unwrap_or_default(),
)
})
.collect(),
})
}
@@ -1087,6 +1248,7 @@ fn provider_request(
fn provider_unit(
task: &PersistedTranslationTask,
unit: &OfficialTextUnitIndexUnit,
glossary_constraints: Vec<GlossaryConstraint>,
) -> TranslationProviderUnit {
TranslationProviderUnit {
unit_id: unit.id.clone(),
@@ -1103,6 +1265,7 @@ fn provider_unit(
text_source_kind: unit.text_source_kind.clone(),
asset_name: unit.asset_name.clone(),
context: unit.context.clone(),
glossary_constraints,
}
}
@@ -1110,6 +1273,7 @@ fn validate_provider_response(
request: &TranslationProviderRequest,
response: TranslationProviderResponse,
provider_name: &str,
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
) -> anyhow::Result<Vec<TranslationTaskUnitResult>> {
if response.provider_run_id != request.provider_run_id {
return Err(anyhow::anyhow!(
@@ -1150,6 +1314,9 @@ fn validate_provider_response(
result.unit_id
));
}
let glossary_qa = glossary_evaluations
.get(&result.unit_id)
.map(|evaluation| evaluation.check_translation(&result.translated_text));
results.push(TranslationTaskUnitResult {
unit_id: result.unit_id,
source_text: result.source_text,
@@ -1159,6 +1326,8 @@ fn validate_provider_response(
provider: provider_name.to_string(),
provider_run_id: request.provider_run_id.clone(),
translated_unix_seconds: unix_seconds_now(),
glossary_qa,
glossary_override: None,
});
}
if seen.len() != expected.len() {
@@ -1175,6 +1344,7 @@ fn translation_memory_result(
task: &PersistedTranslationTask,
unit: &OfficialTextUnitIndexUnit,
entry: &bat_core::domain::TranslationMemoryEntry,
glossary_qa: GlossaryQaReport,
) -> TranslationTaskUnitResult {
TranslationTaskUnitResult {
unit_id: unit.id.clone(),
@@ -1185,6 +1355,8 @@ fn translation_memory_result(
provider: "translation_memory".to_string(),
provider_run_id: task.provider_run_id.clone().unwrap_or_default(),
translated_unix_seconds: unix_seconds_now(),
glossary_qa: Some(glossary_qa),
glossary_override: None,
}
}
@@ -1222,6 +1394,16 @@ fn record_translation_memory_failure(
Ok(())
}
fn record_glossary_failure(context: &WorkerTaskContext<'_>, message: String) -> anyhow::Result<()> {
context
.stats
.glossary_failures
.lock()
.map_err(|_| anyhow::anyhow!("写入 Glossary 诊断时 mutex poisoned"))?
.push(message);
Ok(())
}
async fn record_provider_failure(
context: &WorkerTaskContext<'_>,
task: &PersistedTranslationTask,
@@ -1455,6 +1637,122 @@ mod tests {
assert_eq!(task.translation_results[0].translated_text, "translated-0");
}
#[tokio::test]
async fn worker_sends_approved_glossary_constraints_and_persists_qa() {
let (temp, queue) = fixture_root();
let textunit_index = index(temp.path());
crate::official_textunit_queue::write_textunit_task_queue_at(temp.path(), &queue).unwrap();
crate::official_parse::write_textunit_index_at(temp.path(), &textunit_index).unwrap();
let glossary_path = temp.path().join("glossary.sqlite");
let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap();
glossary
.add(bat_core::domain::GlossaryTermDraft {
term_id: "term-source-0".to_string(),
definition: bat_core::domain::GlossaryTermSnapshot {
source_term: "source-0".to_string(),
aliases: Vec::new(),
recommended_translation: "term-0".to_string(),
allowed_translations: Vec::new(),
source_language: Some("en".to_string()),
target_language: Some("zh-Hans".to_string()),
category: Some("test".to_string()),
priority: 10,
scope: BTreeMap::new(),
},
review_status: bat_core::domain::GlossaryReviewStatus::Approved,
source: bat_core::domain::GlossarySourceRecord {
source_kind: bat_core::domain::GlossarySourceKind::Manual,
source_ref: Some("worker-test".to_string()),
source_author: Some("test".to_string()),
source_note: None,
observed_unix_seconds: 1,
},
})
.await
.unwrap();
#[derive(Debug)]
struct GlossaryProvider {
requests: Arc<Mutex<Vec<TranslationProviderRequest>>>,
}
#[async_trait::async_trait]
impl TranslationProvider for GlossaryProvider {
fn name(&self) -> &'static str {
"glossary-test"
}
async fn translate(
&self,
request: TranslationProviderRequest,
) -> Result<TranslationProviderResponse, TranslationProviderError> {
self.requests.lock().unwrap().push(request.clone());
Ok(TranslationProviderResponse {
provider_run_id: request.provider_run_id,
units: request
.units
.into_iter()
.map(|unit| TranslationProviderUnitResult {
unit_id: unit.unit_id,
source_text: unit.source_text.clone(),
translated_text: if unit.source_text == "source-0" {
"term-0".to_string()
} else {
"translated-1".to_string()
},
})
.collect(),
})
}
}
let requests = Arc::new(Mutex::new(Vec::new()));
let config = TranslationWorkerConfig {
glossary_path: Some(glossary_path),
concurrency: 1,
retry_backoff: Duration::ZERO,
..TranslationWorkerConfig::default()
};
let report = run_translation_worker_with_provider(
temp.path(),
&config,
Arc::new(GlossaryProvider {
requests: Arc::clone(&requests),
}),
)
.await
.unwrap();
assert_eq!(report.completed_count, 1);
assert!(report.glossary_available);
assert_eq!(report.glossary_blocked_count, 0);
{
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].units[0].glossary_constraints.len(), 1);
assert_eq!(
requests[0].units[0].glossary_constraints[0].term_id,
"term-source-0"
);
}
let repository = SqliteTranslationTaskRepository::open(
SqliteTranslationTaskRepository::repository_path(temp.path()),
)
.await
.unwrap();
let task = repository.find(&queue.tasks[0].task_id).await.unwrap();
assert_eq!(task.translation_results[0].translated_text, "term-0");
assert_eq!(
task.translation_results[0]
.glossary_qa
.as_ref()
.map(|qa| qa.status),
Some(bat_core::domain::GlossaryQaStatus::Pass)
);
}
#[tokio::test]
async fn worker_honors_cancellation_before_claiming_tasks() {
let (temp, queue) = fixture_root();
+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,
}],
}
}