mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:04:55 +08:00
fix(tm):建立 Trusted 唯一性与 Supersede 治理
This commit is contained in:
@@ -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-id(winner)、--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}"))?;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user