mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
@@ -4190,6 +4190,7 @@ fn dispatch_translation_tasks_filters_current_queue() {
|
||||
let output_root = temp.path().join("output");
|
||||
let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
|
||||
write_textunit_task_queue_fixture(¤t_dir);
|
||||
write_textunit_index_fixture(¤t_dir);
|
||||
write_translation_task_repository_fixture(¤t_dir);
|
||||
|
||||
let envelope = dispatch_rpc_method(
|
||||
@@ -4290,7 +4291,13 @@ fn dispatch_translation_tasks_filters_current_queue() {
|
||||
Some(serde_json::json!({
|
||||
"task_id": "textunit/v-current/Bundle/a.bundle",
|
||||
"status": "completed",
|
||||
"provider_run_id": "crowdin-run-2"
|
||||
"provider": "manual",
|
||||
"provider_run_id": "manual-run-1",
|
||||
"translation_results": [{
|
||||
"unit_id": "direct:a#unit:0",
|
||||
"source_text": "こんにちは",
|
||||
"translated_text": "你好"
|
||||
}]
|
||||
})),
|
||||
),
|
||||
&state_dir,
|
||||
@@ -4305,7 +4312,16 @@ fn dispatch_translation_tasks_filters_current_queue() {
|
||||
value["data"]["entry"]["failure_reason"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(value["data"]["entry"]["provider_run_id"], "crowdin-run-2");
|
||||
assert_eq!(value["data"]["entry"]["provider_run_id"], "manual-run-1");
|
||||
assert_eq!(value["data"]["entry"]["provider"], "manual");
|
||||
assert_eq!(
|
||||
value["data"]["entry"]["translation_results"][0]["unit_id"],
|
||||
"direct:a#unit:0"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["entry"]["translation_results"][0]["translated_text"],
|
||||
"你好"
|
||||
);
|
||||
assert!(value["data"]["entry"]["completed_unix_seconds"].is_number());
|
||||
|
||||
let envelope = dispatch_rpc_method(
|
||||
@@ -4329,7 +4345,7 @@ fn dispatch_translation_tasks_filters_current_queue() {
|
||||
assert_eq!(value["data"]["handoff"]["units"][0]["status"], "translated");
|
||||
assert_eq!(
|
||||
value["data"]["handoff"]["provider_runs"][0]["provider_run_id"],
|
||||
"crowdin-run-2"
|
||||
"manual-run-1"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TranslationTaskResultUpdateParam {
|
||||
unit_id: String,
|
||||
source_text: String,
|
||||
translated_text: String,
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_tasks_report(
|
||||
state_dir: &Path,
|
||||
query: OfficialTextUnitTaskQuery,
|
||||
@@ -170,6 +177,13 @@ pub(super) fn update_translation_task_status_report(
|
||||
.or_else(|| rpc_string_param(params, "reason"))
|
||||
.map(str::to_string);
|
||||
let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string);
|
||||
let provider = rpc_string_param(params, "provider").map(str::to_string);
|
||||
let result_params = translation_task_result_params(params)?;
|
||||
if !result_params.is_empty() && status != TranslationTaskStatus::Completed {
|
||||
return Err(anyhow::anyhow!(
|
||||
"translation_results 只能随 completed 状态写入"
|
||||
));
|
||||
}
|
||||
let (_, version_state) = read_daemon_resource_state(state_dir)?;
|
||||
let current = version_state
|
||||
.as_ref()
|
||||
@@ -182,6 +196,22 @@ pub(super) fn update_translation_task_status_report(
|
||||
repository_path.display()
|
||||
));
|
||||
}
|
||||
let textunit_index = if result_params.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
read_textunit_index_at(¤t.resource_root)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("当前 release 缺少 TextUnit 明细索引,无法校验人工校对结果")
|
||||
})?,
|
||||
)
|
||||
};
|
||||
let result_provider = provider.clone().unwrap_or_else(|| "manual".to_string());
|
||||
let result_timestamp = unix_seconds_now();
|
||||
let result_provider_run_id = provider_run_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("manual-{result_timestamp}"));
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -190,10 +220,36 @@ pub(super) fn update_translation_task_status_report(
|
||||
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.update_status(task_id, status, failure_reason, provider_run_id)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
if let Some(index) = textunit_index.as_ref() {
|
||||
let current_task = repository
|
||||
.find(task_id)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
let results = build_manual_translation_results(
|
||||
¤t_task,
|
||||
index,
|
||||
&result_params,
|
||||
&result_provider,
|
||||
&result_provider_run_id,
|
||||
result_timestamp,
|
||||
)?;
|
||||
repository
|
||||
.update_status_with_results(
|
||||
task_id,
|
||||
status,
|
||||
failure_reason,
|
||||
Some(result_provider_run_id),
|
||||
Some(result_provider),
|
||||
Some(&results),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
} else {
|
||||
repository
|
||||
.update_status(task_id, status, failure_reason, provider_run_id)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
}
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
@@ -215,6 +271,75 @@ pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::
|
||||
})
|
||||
}
|
||||
|
||||
fn translation_task_result_params(
|
||||
params: Option<&serde_json::Value>,
|
||||
) -> anyhow::Result<Vec<TranslationTaskResultUpdateParam>> {
|
||||
let Some(value) = params
|
||||
.and_then(|params| params.get("translation_results"))
|
||||
.or_else(|| params.and_then(|params| params.get("results")))
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if value.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
serde_json::from_value(value.clone())
|
||||
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
|
||||
}
|
||||
|
||||
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,
|
||||
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
|
||||
let index_by_id = index
|
||||
.units
|
||||
.iter()
|
||||
.map(|unit| (unit.id.as_str(), unit))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut results = Vec::with_capacity(params.len());
|
||||
for param in params {
|
||||
let unit_id = param.unit_id.trim();
|
||||
if unit_id.is_empty() {
|
||||
return Err(anyhow::anyhow!("translation_results[].unit_id 不能为空"));
|
||||
}
|
||||
if !seen.insert(unit_id.to_string()) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"translation_results 包含重复 TextUnit:{unit_id}"
|
||||
));
|
||||
}
|
||||
let unit = index_by_id
|
||||
.get(unit_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("translation_results 引用了未知 TextUnit:{unit_id}"))?;
|
||||
if unit.destination != task.task.destination
|
||||
|| unit.archive_entry != task.task.archive_entry
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {unit_id} 不属于翻译任务 {}",
|
||||
task.task.task_id
|
||||
));
|
||||
}
|
||||
if param.source_text != unit.source_text {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
|
||||
));
|
||||
}
|
||||
results.push(bat_infrastructure::TranslationTaskUnitResult {
|
||||
unit_id: unit_id.to_string(),
|
||||
source_text: param.source_text.clone(),
|
||||
translated_text: param.translated_text.clone(),
|
||||
provider: provider.to_string(),
|
||||
provider_run_id: provider_run_id.to_string(),
|
||||
translated_unix_seconds,
|
||||
});
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"task_id": query.task_id.clone(),
|
||||
|
||||
@@ -1217,6 +1217,27 @@ impl SqliteTranslationTaskRepository {
|
||||
status: TranslationTaskStatus,
|
||||
failure_reason: Option<String>,
|
||||
provider_run_id: Option<String>,
|
||||
) -> Result<PersistedTranslationTask> {
|
||||
self.update_status_with_results(
|
||||
task_id,
|
||||
status,
|
||||
failure_reason,
|
||||
provider_run_id,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates provider state and optionally replaces durable TextUnit results.
|
||||
pub async fn update_status_with_results(
|
||||
&self,
|
||||
task_id: &str,
|
||||
status: TranslationTaskStatus,
|
||||
failure_reason: Option<String>,
|
||||
provider_run_id: Option<String>,
|
||||
provider: Option<String>,
|
||||
translation_results: Option<&[TranslationTaskUnitResult]>,
|
||||
) -> Result<PersistedTranslationTask> {
|
||||
let current = self.find(task_id).await?;
|
||||
let now = unix_seconds_now_i64();
|
||||
@@ -1228,6 +1249,13 @@ impl SqliteTranslationTaskRepository {
|
||||
current.attempt_count
|
||||
};
|
||||
let normalized_reason = failure_reason.filter(|reason| !reason.trim().is_empty());
|
||||
let provider_run_id = provider_run_id.filter(|value| !value.trim().is_empty());
|
||||
let provider = provider.filter(|value| !value.trim().is_empty());
|
||||
let translation_results_json =
|
||||
translation_results
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.map_err(|error| bat_core::Error::Serialization(error.to_string()))?;
|
||||
let completed = (status == TranslationTaskStatus::Completed).then_some(now);
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -1235,6 +1263,8 @@ impl SqliteTranslationTaskRepository {
|
||||
SET worker_status = ?2, failure_reason = ?3, attempt_count = ?4,
|
||||
updated_unix_seconds = ?5, completed_unix_seconds = ?6,
|
||||
provider_run_id = COALESCE(?7, provider_run_id),
|
||||
provider = COALESCE(?8, provider),
|
||||
translation_results_json = COALESCE(?9, translation_results_json),
|
||||
lease_owner = NULL, lease_expires_unix_seconds = NULL,
|
||||
failure_class = NULL, failure_retryable = 0,
|
||||
next_attempt_unix_seconds = NULL
|
||||
@@ -1248,6 +1278,8 @@ impl SqliteTranslationTaskRepository {
|
||||
.bind(now)
|
||||
.bind(completed)
|
||||
.bind(provider_run_id)
|
||||
.bind(provider)
|
||||
.bind(translation_results_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
@@ -1696,6 +1728,52 @@ mod tests {
|
||||
assert_eq!(retrievable[0].attempt_count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_translation_tasks_persist_manual_results_without_worker_lease() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let repository =
|
||||
SqliteTranslationTaskRepository::new(temp.path().join("translation-tasks.sqlite"))
|
||||
.await
|
||||
.unwrap();
|
||||
let queue = queue(vec![task(
|
||||
"task-a",
|
||||
"Bundles/a.bundle",
|
||||
OfficialTextUnitTaskStatus::QueuedOffline,
|
||||
Some(OfficialParseStatus::Parsed),
|
||||
None,
|
||||
)]);
|
||||
repository.sync_queue(&queue).await.unwrap();
|
||||
let result = TranslationTaskUnitResult {
|
||||
unit_id: "unit-a".to_string(),
|
||||
source_text: "source".to_string(),
|
||||
translated_text: "manual translation".to_string(),
|
||||
provider: "manual".to_string(),
|
||||
provider_run_id: "manual-run-1".to_string(),
|
||||
translated_unix_seconds: 321,
|
||||
};
|
||||
|
||||
let updated = repository
|
||||
.update_status_with_results(
|
||||
"task-a",
|
||||
TranslationTaskStatus::Completed,
|
||||
None,
|
||||
Some("manual-run-1".to_string()),
|
||||
Some("manual".to_string()),
|
||||
Some(std::slice::from_ref(&result)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.task_status, TranslationTaskStatus::Completed);
|
||||
assert_eq!(updated.provider.as_deref(), Some("manual"));
|
||||
assert_eq!(updated.provider_run_id.as_deref(), Some("manual-run-1"));
|
||||
assert_eq!(updated.translation_results, vec![result.clone()]);
|
||||
assert_eq!(
|
||||
repository.find("task-a").await.unwrap().translation_results,
|
||||
vec![result]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_translation_tasks_recover_expired_leases_for_retry() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user