fix(tm):建立 Trusted 唯一性与 Supersede 治理

This commit is contained in:
2026-09-18 06:44:14 +08:00
parent e486f1aaaa
commit ff1adb91ee
37 changed files with 2679 additions and 530 deletions
+265 -32
View File
@@ -106,7 +106,8 @@ use terminal_output::RotatingStructuredLogger;
use terminal_output::STARTUP_BANNER;
use translation_query::{
build_translation_handoff_report, build_translation_memory_confirm_report,
build_translation_memory_query_report, build_translation_memory_summary_report,
build_translation_memory_conflicts_report, build_translation_memory_query_report,
build_translation_memory_resolve_conflict_report, build_translation_memory_summary_report,
build_translation_tasks_report, run_translation_memory_command, textunit_query_json,
update_translation_task_status_report,
};
@@ -242,7 +243,9 @@ fn run() -> anyhow::Result<i32> {
}
CliCommand::TranslationMemorySummary
| CliCommand::TranslationMemoryQuery
| CliCommand::TranslationMemoryConfirm => {
| CliCommand::TranslationMemoryConfirm
| CliCommand::TranslationMemoryConflicts
| CliCommand::TranslationMemoryResolveConflict => {
run_translation_memory_command(&options)?;
Ok(0)
}
@@ -421,6 +424,8 @@ struct CliOptions {
translation_memory_source_text: Option<String>,
translation_memory_context_json: Option<String>,
translation_memory_record_id: Option<String>,
translation_memory_supersede_record_id: Option<String>,
translation_memory_expected_trusted_record_ids_json: Option<String>,
translation_memory_reviewer: Option<String>,
translation_memory_reason: Option<String>,
glossary_term_id: Option<String>,
@@ -548,6 +553,8 @@ impl Default for CliOptions {
translation_memory_source_text: None,
translation_memory_context_json: None,
translation_memory_record_id: None,
translation_memory_supersede_record_id: None,
translation_memory_expected_trusted_record_ids_json: None,
translation_memory_reviewer: None,
translation_memory_reason: None,
glossary_term_id: None,
@@ -666,6 +673,8 @@ enum CliCommand {
TranslationMemorySummary,
TranslationMemoryQuery,
TranslationMemoryConfirm,
TranslationMemoryConflicts,
TranslationMemoryResolveConflict,
GlossarySummary,
GlossaryQuery,
GlossaryAdd,
@@ -1223,6 +1232,8 @@ 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_TRANSLATION_MEMORY_CONFLICTS: &str = "translation.memory.conflicts";
const RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT: &str = "translation.memory.resolve_conflict";
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";
@@ -2304,6 +2315,30 @@ fn dispatch_rpc_method(
request.params.as_ref(),
),
),
RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS => translation_memory_rpc_envelope(
request_id,
translation_memory_conflicts_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks
.translation_worker_config
.translation_memory_path
.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT => translation_memory_rpc_envelope(
request_id,
translation_memory_resolve_conflict_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks
.translation_worker_config
.translation_memory_path
.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_SUMMARY => glossary_rpc_envelope(
request_id,
glossary_summary_rpc_report(
@@ -4157,6 +4192,28 @@ fn translation_memory_rpc_envelope(
}
}
fn translation_memory_api_error(method: &'static str, error: anyhow::Error) -> ApiError {
let message = error.to_string();
let code = if message.starts_with("Invalid argument:")
|| message.starts_with("Object not found:")
|| [
"trusted_conflict_requires_resolution",
"explicit_supersede_required",
"trusted_translation_already_exists",
"stale_supersede_target",
"conflict_snapshot_stale",
"invalid_conflict_winner",
]
.iter()
.any(|marker| message.contains(marker))
{
ErrorCode::RPC_INVALID_PARAMS
} else {
ErrorCode::INTERNAL
};
ApiError::new(code, method, message)
}
fn translation_memory_rpc_params<'a>(
params: Option<&'a serde_json::Value>,
method: &'static str,
@@ -4240,17 +4297,16 @@ fn translation_memory_rpc_context(
})
}
fn translation_memory_rpc_limit(params: Option<&serde_json::Value>) -> Result<usize, ApiError> {
let limit = match translation_memory_rpc_value(
params,
&["limit"],
RPC_METHOD_TRANSLATION_MEMORY_QUERY,
)? {
fn translation_memory_rpc_limit(
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<usize, ApiError> {
let limit = match translation_memory_rpc_value(params, &["limit"], method)? {
None | Some(serde_json::Value::Null) => 100,
Some(value) => value.as_u64().ok_or_else(|| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_MEMORY_QUERY,
method,
"limit 必须是非负整数 JSON number",
)
})?,
@@ -4258,14 +4314,14 @@ fn translation_memory_rpc_limit(params: Option<&serde_json::Value>) -> Result<us
let limit = usize::try_from(limit).map_err(|error| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_MEMORY_QUERY,
method,
format!("limit 无效:{error}"),
)
})?;
if !(1..=1000).contains(&limit) {
return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_MEMORY_QUERY,
method,
"limit 必须在 1..=1000 范围内",
));
}
@@ -4285,13 +4341,8 @@ fn translation_memory_summary_rpc_report(
params,
RPC_METHOD_TRANSLATION_MEMORY_SUMMARY,
)?;
build_translation_memory_summary_report(&path).map_err(|error| {
ApiError::new(
ErrorCode::INTERNAL,
RPC_METHOD_TRANSLATION_MEMORY_SUMMARY,
error.to_string(),
)
})
build_translation_memory_summary_report(&path)
.map_err(|error| translation_memory_api_error(RPC_METHOD_TRANSLATION_MEMORY_SUMMARY, error))
}
fn translation_memory_query_rpc_report(
@@ -4314,7 +4365,7 @@ fn translation_memory_query_rpc_report(
)
})?;
let context = translation_memory_rpc_context(params)?;
let limit = translation_memory_rpc_limit(params)?;
let limit = translation_memory_rpc_limit(params, RPC_METHOD_TRANSLATION_MEMORY_QUERY)?;
let path = translation_memory_rpc_path(
state_dir,
output_root,
@@ -4322,13 +4373,8 @@ fn translation_memory_query_rpc_report(
params,
RPC_METHOD_TRANSLATION_MEMORY_QUERY,
)?;
build_translation_memory_query_report(&path, source_text, &context, limit).map_err(|error| {
ApiError::new(
ErrorCode::INTERNAL,
RPC_METHOD_TRANSLATION_MEMORY_QUERY,
error.to_string(),
)
})
build_translation_memory_query_report(&path, source_text, &context, limit)
.map_err(|error| translation_memory_api_error(RPC_METHOD_TRANSLATION_MEMORY_QUERY, error))
}
fn translation_memory_confirm_rpc_report(
@@ -4370,6 +4416,13 @@ fn translation_memory_confirm_rpc_report(
"reason",
)?
.map(str::to_string);
let supersede_record_id = translation_memory_rpc_string_param(
params,
&["supersede_record_id", "tm_supersede_record_id"],
RPC_METHOD_TRANSLATION_MEMORY_CONFIRM,
"supersede_record_id",
)?
.map(str::to_string);
let path = translation_memory_rpc_path(
state_dir,
output_root,
@@ -4377,12 +4430,127 @@ fn translation_memory_confirm_rpc_report(
params,
RPC_METHOD_TRANSLATION_MEMORY_CONFIRM,
)?;
build_translation_memory_confirm_report(&path, record_id, reviewer, reason).map_err(|error| {
build_translation_memory_confirm_report(
&path,
record_id,
reviewer,
reason,
supersede_record_id.as_deref(),
)
.map_err(|error| translation_memory_api_error(RPC_METHOD_TRANSLATION_MEMORY_CONFIRM, error))
}
fn translation_memory_conflicts_rpc_report(
state_dir: &Path,
output_root: &Path,
default_translation_memory_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let limit = translation_memory_rpc_limit(params, RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS)?;
let path = translation_memory_rpc_path(
state_dir,
output_root,
default_translation_memory_path,
params,
RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS,
)?;
build_translation_memory_conflicts_report(&path, limit).map_err(|error| {
translation_memory_api_error(RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS, error)
})
}
fn translation_memory_resolve_conflict_rpc_report(
state_dir: &Path,
output_root: &Path,
default_translation_memory_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let winner_record_id = translation_memory_rpc_string_param(
params,
&["winner_record_id", "record_id"],
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
"winner_record_id",
)?
.ok_or_else(|| {
ApiError::new(
ErrorCode::INTERNAL,
RPC_METHOD_TRANSLATION_MEMORY_CONFIRM,
error.to_string(),
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
"translation.memory.resolve_conflict 缺少 winner_record_id",
)
})?;
let reviewer = translation_memory_rpc_string_param(
params,
&["reviewer"],
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
"reviewer",
)?
.ok_or_else(|| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
"translation.memory.resolve_conflict 缺少 reviewer",
)
})?;
let reason = translation_memory_rpc_string_param(
params,
&["reason"],
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
"reason",
)?
.ok_or_else(|| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
"translation.memory.resolve_conflict 缺少 reason",
)
})?;
let expected = translation_memory_rpc_value(
params,
&["expected_trusted_record_ids"],
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
)?
.ok_or_else(|| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
"translation.memory.resolve_conflict 缺少 expected_trusted_record_ids",
)
})?;
let expected = expected.as_array().ok_or_else(|| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
"expected_trusted_record_ids 必须是 JSON array",
)
})?;
let expected = expected
.iter()
.map(|value| {
value.as_str().map(str::to_string).ok_or_else(|| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
"expected_trusted_record_ids 必须只包含字符串",
)
})
})
.collect::<Result<Vec<_>, _>>()?;
let path = translation_memory_rpc_path(
state_dir,
output_root,
default_translation_memory_path,
params,
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT,
)?;
build_translation_memory_resolve_conflict_report(
&path,
winner_record_id,
&expected,
reviewer,
reason,
)
.map_err(|error| {
translation_memory_api_error(RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, error)
})
}
@@ -7350,10 +7518,20 @@ fn parse_args_with_env(
Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
}
"--tm-record-id" => {
"--tm-record-id" | "--tm-winner-record-id" => {
options.translation_memory_record_id = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
}
"--tm-supersede-record-id" => {
options.translation_memory_supersede_record_id =
Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
}
"--tm-expected-trusted-record-ids-json" => {
options.translation_memory_expected_trusted_record_ids_json =
Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
}
"--tm-reviewer" => {
options.translation_memory_reviewer = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
@@ -7938,6 +8116,8 @@ fn parse_args_with_env(
CliCommand::TranslationMemorySummary
| CliCommand::TranslationMemoryQuery
| CliCommand::TranslationMemoryConfirm
| CliCommand::TranslationMemoryConflicts
| CliCommand::TranslationMemoryResolveConflict
| CliCommand::Restart
| CliCommand::Reload
)
@@ -7981,6 +8161,8 @@ fn parse_args_with_env(
| CliCommand::TranslationMemorySummary
| CliCommand::TranslationMemoryQuery
| CliCommand::TranslationMemoryConfirm
| CliCommand::TranslationMemoryConflicts
| CliCommand::TranslationMemoryResolveConflict
| CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
@@ -8004,6 +8186,8 @@ fn parse_args_with_env(
CliCommand::TranslationMemorySummary
| CliCommand::TranslationMemoryQuery
| CliCommand::TranslationMemoryConfirm
| CliCommand::TranslationMemoryConflicts
| CliCommand::TranslationMemoryResolveConflict
)
{
return Err(anyhow::anyhow!(
@@ -8044,7 +8228,9 @@ fn parse_args_with_env(
}
CliCommand::TranslationMemorySummary
| CliCommand::TranslationMemoryQuery
| CliCommand::TranslationMemoryConfirm => {
| CliCommand::TranslationMemoryConfirm
| CliCommand::TranslationMemoryConflicts
| CliCommand::TranslationMemoryResolveConflict => {
if options.watch || options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("i18n memory 命令只支持单次执行或 RPC 调用"));
}
@@ -8079,6 +8265,10 @@ fn parse_args_with_env(
}
CliCommand::TranslationMemoryQuery => {
if options.translation_memory_record_id.is_some()
|| options.translation_memory_supersede_record_id.is_some()
|| options
.translation_memory_expected_trusted_record_ids_json
.is_some()
|| options.translation_memory_reviewer.is_some()
|| options.translation_memory_reason.is_some()
{
@@ -8088,11 +8278,38 @@ fn parse_args_with_env(
CliCommand::TranslationMemoryConfirm => {
if options.translation_memory_source_text.is_some()
|| options.translation_memory_context_json.is_some()
|| options
.translation_memory_expected_trusted_record_ids_json
.is_some()
|| options.query_limit != 100
{
return Err(anyhow::anyhow!("i18n memory confirm 不接受 query 参数"));
}
}
CliCommand::TranslationMemoryConflicts => {
if options.translation_memory_source_text.is_some()
|| options.translation_memory_context_json.is_some()
|| options.translation_memory_record_id.is_some()
|| options.translation_memory_supersede_record_id.is_some()
|| options.translation_memory_reviewer.is_some()
|| options.translation_memory_reason.is_some()
{
return Err(anyhow::anyhow!(
"i18n memory conflicts 不接受 confirm/query 参数"
));
}
}
CliCommand::TranslationMemoryResolveConflict => {
if options.translation_memory_source_text.is_some()
|| options.translation_memory_context_json.is_some()
|| options.translation_memory_supersede_record_id.is_some()
|| options.query_limit != 100
{
return Err(anyhow::anyhow!(
"i18n memory resolve-conflict 不接受 query/supersede 参数"
));
}
}
_ => unreachable!(),
}
if matches!(options.command, CliCommand::TranslationMemoryQuery)
@@ -8110,6 +8327,20 @@ fn parse_args_with_env(
"i18n memory confirm 必须指定 --tm-record-id 和 --tm-reviewer"
));
}
if matches!(
options.command,
CliCommand::TranslationMemoryResolveConflict
) && (options.translation_memory_record_id.is_none()
|| options
.translation_memory_expected_trusted_record_ids_json
.is_none()
|| options.translation_memory_reviewer.is_none()
|| options.translation_memory_reason.is_none())
{
return Err(anyhow::anyhow!(
"i18n memory resolve-conflict 必须指定 --tm-record-idwinner)、--tm-expected-trusted-record-ids-json、--tm-reviewer 和 --tm-reason"
));
}
options.progress = false;
options.banner = false;
}
@@ -8779,6 +9010,8 @@ fn parse_translation_memory_command(
"summary" | "status" => CliCommand::TranslationMemorySummary,
"query" | "find" => CliCommand::TranslationMemoryQuery,
"confirm" | "trust" => CliCommand::TranslationMemoryConfirm,
"conflicts" | "list-conflicts" => CliCommand::TranslationMemoryConflicts,
"resolve-conflict" | "resolve" => CliCommand::TranslationMemoryResolveConflict,
other => return Err(anyhow::anyhow!("未知 translation memory 二级命令:{other}")),
};
ensure_command_not_set(options.command, &format!("translation memory {action}"))?;
+54
View File
@@ -260,6 +260,47 @@ fn translation_memory_commands_parse_and_validate() {
])
.unwrap();
assert_eq!(confirm.command, CliCommand::TranslationMemoryConfirm);
let supersede = parse(&[
"bat",
"i18n",
"memory",
"confirm",
"--tm-record-id",
"tm-new",
"--tm-supersede-record-id",
"tm-old",
"--tm-reviewer",
"reviewer",
"--tm-reason",
"replacement",
])
.unwrap();
assert_eq!(supersede.command, CliCommand::TranslationMemoryConfirm);
assert_eq!(
supersede.translation_memory_supersede_record_id.as_deref(),
Some("tm-old")
);
let conflicts = parse(&["bat", "i18n", "memory", "conflicts"]).unwrap();
assert_eq!(conflicts.command, CliCommand::TranslationMemoryConflicts);
let resolve = parse(&[
"bat",
"i18n",
"memory",
"resolve-conflict",
"--tm-record-id",
"tm-winner",
"--tm-expected-trusted-record-ids-json",
r#"["tm-old","tm-other"]"#,
"--tm-reviewer",
"reviewer",
"--tm-reason",
"selected",
])
.unwrap();
assert_eq!(
resolve.command,
CliCommand::TranslationMemoryResolveConflict
);
assert!(parse(&["bat", "i18n", "memory", "query"]).is_err());
assert!(parse(&[
"bat",
@@ -2914,6 +2955,19 @@ fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
"translation.memory.confirm",
Some(serde_json::json!({ "record_id": "tm-record", "reviewer": 42 })),
),
(
"translation.memory.conflicts",
Some(serde_json::json!({ "limit": "1" })),
),
(
"translation.memory.resolve_conflict",
Some(serde_json::json!({
"winner_record_id": "tm-winner",
"expected_trusted_record_ids": "tm-old",
"reviewer": "reviewer",
"reason": "selected"
})),
),
] {
let envelope = dispatch_rpc_method(
&rpc_request(method, params),
+155 -1
View File
@@ -433,6 +433,10 @@ pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Re
CliCommand::TranslationMemorySummary => RPC_METHOD_TRANSLATION_MEMORY_SUMMARY,
CliCommand::TranslationMemoryQuery => RPC_METHOD_TRANSLATION_MEMORY_QUERY,
CliCommand::TranslationMemoryConfirm => RPC_METHOD_TRANSLATION_MEMORY_CONFIRM,
CliCommand::TranslationMemoryConflicts => RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS,
CliCommand::TranslationMemoryResolveConflict => {
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT
}
_ => return Err(anyhow::anyhow!("不是 Translation Memory 命令")),
};
if daemon_rpc_available(&options.state_dir)
@@ -480,6 +484,34 @@ pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Re
record_id,
reviewer,
options.translation_memory_reason.clone(),
options.translation_memory_supersede_record_id.as_deref(),
)?
}
CliCommand::TranslationMemoryConflicts => {
build_translation_memory_conflicts_report(&path, options.query_limit)?
}
CliCommand::TranslationMemoryResolveConflict => {
let winner = options
.translation_memory_record_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 winner record"))?;
let expected = options
.translation_memory_expected_trusted_record_ids_json
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 expected set"))?;
let expected = serde_json::from_str::<Vec<String>>(expected).map_err(|error| {
anyhow::anyhow!("expected trusted record IDs 必须是 JSON array{error}")
})?;
let reviewer = options
.translation_memory_reviewer
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reviewer"))?;
let reason = options
.translation_memory_reason
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reason"))?;
build_translation_memory_resolve_conflict_report(
&path, winner, &expected, reviewer, reason,
)?
}
_ => unreachable!(),
@@ -538,10 +570,49 @@ fn translation_memory_cli_params(
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-reviewer"))?;
params.insert("record_id".to_string(), serde_json::json!(record_id));
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
if let Some(supersede_record_id) =
options.translation_memory_supersede_record_id.as_deref()
{
params.insert(
"supersede_record_id".to_string(),
serde_json::json!(supersede_record_id),
);
}
if let Some(reason) = options.translation_memory_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
CliCommand::TranslationMemoryConflicts => {
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
}
CliCommand::TranslationMemoryResolveConflict => {
let winner = options
.translation_memory_record_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 winner record"))?;
let expected = options
.translation_memory_expected_trusted_record_ids_json
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 expected set"))?;
let reviewer = options
.translation_memory_reviewer
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reviewer"))?;
let reason = options
.translation_memory_reason
.as_deref()
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reason"))?;
let expected = serde_json::from_str::<Vec<String>>(expected).map_err(|error| {
anyhow::anyhow!("expected trusted record IDs 必须是 JSON array{error}")
})?;
params.insert("winner_record_id".to_string(), serde_json::json!(winner));
params.insert(
"expected_trusted_record_ids".to_string(),
serde_json::json!(expected),
);
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
params.insert("reason".to_string(), serde_json::json!(reason));
}
_ => unreachable!(),
}
Ok(Some(serde_json::Value::Object(params)))
@@ -625,6 +696,7 @@ pub(super) fn build_translation_memory_confirm_report(
record_id: &str,
reviewer: &str,
reason: Option<String>,
supersede_record_id: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
if record_id.trim().is_empty() || reviewer.trim().is_empty() {
return Err(anyhow::anyhow!(
@@ -645,7 +717,89 @@ pub(super) fn build_translation_memory_confirm_report(
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.confirm(record_id, reviewer, reason)
.confirm_with_supersede(record_id, reviewer, reason, supersede_record_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"entry": entry,
}))
}
pub(super) fn build_translation_memory_conflicts_report(
path: &std::path::Path,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
if !(1..=1000).contains(&limit) {
return Err(anyhow::anyhow!(
"TM conflicts 的 limit 必须在 1..=1000 范围内"
));
}
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"conflicts": [],
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let conflicts = runtime.block_on(async {
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.list_conflicts(limit)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"conflicts": conflicts,
}))
}
pub(super) fn build_translation_memory_resolve_conflict_report(
path: &std::path::Path,
winner_record_id: &str,
expected_trusted_record_ids: &[String],
reviewer: &str,
reason: &str,
) -> anyhow::Result<serde_json::Value> {
if winner_record_id.trim().is_empty()
|| reviewer.trim().is_empty()
|| reason.trim().is_empty()
|| expected_trusted_record_ids.is_empty()
{
return Err(anyhow::anyhow!(
"TM resolve-conflict 必须指定 winner_record_id、expected_trusted_record_ids、reviewer 和 reason"
));
}
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
return Err(anyhow::anyhow!(
"Translation Memory 数据库不存在:{}",
path.display()
));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let entry = runtime.block_on(async {
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.resolve_conflict(
winner_record_id,
expected_trusted_record_ids,
reviewer,
reason,
)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
+1 -1
View File
@@ -1,4 +1,4 @@
//! Project-level Glossary V2 SQLite repository.
//! Project-level Glossary SQLite persistence schema V2 repository.
use crate::path_security::{
ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE,
File diff suppressed because it is too large Load Diff
+132
View File
@@ -1522,6 +1522,7 @@ mod tests {
OfficialTextUnitTask, OfficialTextUnitTaskQueue, OfficialTextUnitTaskStatus,
OfficialTextUnitTaskSummary, OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION,
};
use bat_core::domain::TranslationMemoryMatchKind;
fn fixture_root() -> (tempfile::TempDir, OfficialTextUnitTaskQueue) {
let temp = tempfile::TempDir::new().unwrap();
@@ -1917,6 +1918,137 @@ mod tests {
);
}
#[tokio::test]
async fn worker_does_not_reuse_a_trusted_conflict() {
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 translation_memory_path = temp.path().join("translation-memory.sqlite");
let translation_memory = SqliteTranslationMemoryRepository::new(&translation_memory_path)
.await
.unwrap();
let unit = &textunit_index.units[0];
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 mut trusted_ids = Vec::new();
for (release, translated) in [("release-1", "旧译文一"), ("release-2", "旧译文二")]
{
let entry = translation_memory
.upsert_candidate(TranslationMemoryDraft {
source_text: unit.source_text.clone(),
source_context: source_context.clone(),
translated_text: translated.to_string(),
translation_source_kind: TranslationMemorySourceKind::Manual,
official_release_id: release.to_string(),
source_trace: TranslationMemorySourceTrace {
official_release_id: release.to_string(),
unit_id: Some(unit.id.clone()),
task_id: Some(queue.tasks[0].task_id.clone()),
destination: Some(unit.destination.clone()),
archive_entry: unit.archive_entry.clone(),
serialized_file: unit.serialized_file.clone(),
path_id: unit.path_id,
class_id: unit.class_id,
field_path: unit.field_path.clone(),
format: unit.format.clone(),
asset_name: unit.asset_name.clone(),
text_source_kind: unit.text_source_kind.clone(),
source_url: Some(unit.source_url.clone()),
},
provider: None,
provider_run_id: None,
observed_unix_seconds: 1,
})
.await
.unwrap();
sqlx::query(
"UPDATE translation_memory
SET trust_status = 'trusted', trusted_unix_seconds = 100,
trusted_by = 'legacy-reviewer', trusted_reason = 'legacy fixture'
WHERE record_id = ?1",
)
.bind(&entry.record_id)
.execute(&translation_memory.pool)
.await
.unwrap();
trusted_ids.push(entry.record_id);
}
assert_eq!(
translation_memory
.find_matches(&unit.source_text, &source_context, 10)
.await
.unwrap()
.iter()
.filter(|item| item.match_kind == TranslationMemoryMatchKind::TrustedConflict)
.count(),
2
);
let fixture = temp.path().join("conflict-mock.json");
std::fs::write(
&fixture,
serde_json::to_vec(&serde_json::json!({
"schema_version": 1,
"translations": {
"direct:bundle#unit:0": "provider-after-conflict",
"direct:bundle#unit:1": "translated-by-provider"
}
}))
.unwrap(),
)
.unwrap();
let config = TranslationWorkerConfig {
fixture_path: Some(fixture),
translation_memory_path: Some(translation_memory_path),
concurrency: 1,
retry_backoff: Duration::ZERO,
..TranslationWorkerConfig::default()
};
let report = run_translation_worker_at(temp.path(), &config)
.await
.unwrap();
assert_eq!(report.translation_memory_hit_count, 0);
assert_eq!(report.provider_unit_count, 2);
assert_eq!(report.completed_count, 1);
let task_repository = SqliteTranslationTaskRepository::open(
SqliteTranslationTaskRepository::repository_path(temp.path()),
)
.await
.unwrap();
let task = task_repository.find(&queue.tasks[0].task_id).await.unwrap();
assert_eq!(task.translation_results.len(), 2);
assert!(task
.translation_results
.iter()
.all(|result| result.source_kind == TranslationTaskResultSourceKind::Provider));
assert!(task.translation_results[0]
.translation_memory_record_id
.is_none());
for record_id in trusted_ids {
assert_eq!(
translation_memory
.find(&record_id)
.await
.unwrap()
.trust_status,
bat_core::domain::TranslationMemoryTrustStatus::Trusted
);
}
}
#[tokio::test]
async fn worker_retains_tm_hits_when_provider_fails_for_remaining_units() {
let (temp, queue) = fixture_root();