feat(translation): add Rust Translation Memory and config migration

This commit is contained in:
2026-09-06 22:48:51 +08:00
parent 93f4bc69b3
commit 7d6389806b
18 changed files with 4414 additions and 384 deletions
+69 -9
View File
@@ -161,6 +161,12 @@ pub struct TranslationTaskUnitResult {
pub source_text: String,
/// Provider-produced or human-supplied translation.
pub translated_text: String,
/// Result source kind.
#[serde(default)]
pub source_kind: TranslationTaskResultSourceKind,
/// Trusted Translation Memory record used for this result, when applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_memory_record_id: Option<String>,
/// Provider identifier.
pub provider: String,
/// Provider run that produced this result.
@@ -169,6 +175,30 @@ pub struct TranslationTaskUnitResult {
pub translated_unix_seconds: u64,
}
/// Source of one persisted TextUnit translation result.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranslationTaskResultSourceKind {
/// Result returned by the configured provider.
#[default]
Provider,
/// Result submitted through the manual task update interface.
Manual,
/// Result reused from a trusted Translation Memory entry.
TranslationMemory,
}
impl TranslationTaskResultSourceKind {
/// Returns the stable JSON label.
pub const fn as_str(self) -> &'static str {
match self {
Self::Provider => "provider",
Self::Manual => "manual",
Self::TranslationMemory => "translation_memory",
}
}
}
/// One provider execution associated with one or more translation units.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderRun {
@@ -247,12 +277,18 @@ pub fn build_translation_handoff(
Vec::new(),
)
});
let unit_status = match state.0 {
TranslationTaskStatus::Queued => TranslationUnitStatus::Queued,
TranslationTaskStatus::Running => TranslationUnitStatus::Translating,
TranslationTaskStatus::Failed => TranslationUnitStatus::Failed,
TranslationTaskStatus::Completed => TranslationUnitStatus::Translated,
TranslationTaskStatus::Skipped => TranslationUnitStatus::Skipped,
let unit_status = if state.5.is_empty() {
match state.0 {
TranslationTaskStatus::Queued => TranslationUnitStatus::Queued,
TranslationTaskStatus::Running => TranslationUnitStatus::Translating,
TranslationTaskStatus::Failed => TranslationUnitStatus::Failed,
TranslationTaskStatus::Completed => TranslationUnitStatus::Translated,
TranslationTaskStatus::Skipped => TranslationUnitStatus::Skipped,
}
} else {
// A task can retain successful TM hits while the remaining provider
// units are failed or waiting for retry.
TranslationUnitStatus::Translated
};
let unit = TranslationUnit {
unit_id: task.task_id.clone(),
@@ -1168,11 +1204,29 @@ impl SqliteTranslationTaskRepository {
pub async fn fail_claim(
&self,
failure: TranslationTaskFailure,
) -> Result<PersistedTranslationTask> {
self.fail_claim_with_results(failure, &[]).await
}
/// Records a provider failure while retaining any already-resolved TextUnit
/// results, such as trusted Translation Memory hits.
pub async fn fail_claim_with_results(
&self,
failure: TranslationTaskFailure,
translation_results: &[TranslationTaskUnitResult],
) -> Result<PersistedTranslationTask> {
let now = unix_seconds_now_i64();
let next_attempt = failure
.next_attempt_unix_seconds
.map(|value| i64::try_from(value).unwrap_or(i64::MAX));
let translation_results_json = if translation_results.is_empty() {
None
} else {
Some(
serde_json::to_string(translation_results)
.map_err(|error| bat_core::Error::Serialization(error.to_string()))?,
)
};
let result = sqlx::query(
r#"
UPDATE translation_tasks
@@ -1183,11 +1237,12 @@ impl SqliteTranslationTaskRepository {
lease_expires_unix_seconds = NULL,
failure_class = ?4,
failure_retryable = ?5,
next_attempt_unix_seconds = ?6
next_attempt_unix_seconds = ?6,
translation_results_json = COALESCE(?7, translation_results_json)
WHERE task_id = ?1
AND worker_status = 'running'
AND lease_owner = ?7
AND provider_run_id = ?8
AND lease_owner = ?8
AND provider_run_id = ?9
"#,
)
.bind(&failure.task_id)
@@ -1196,6 +1251,7 @@ impl SqliteTranslationTaskRepository {
.bind(&failure.failure_class)
.bind(if failure.retryable { 1_i64 } else { 0_i64 })
.bind(next_attempt)
.bind(translation_results_json)
.bind(&failure.worker_id)
.bind(&failure.provider_run_id)
.execute(&self.pool)
@@ -1747,6 +1803,8 @@ mod tests {
unit_id: "unit-a".to_string(),
source_text: "source".to_string(),
translated_text: "manual translation".to_string(),
source_kind: TranslationTaskResultSourceKind::Manual,
translation_memory_record_id: None,
provider: "manual".to_string(),
provider_run_id: "manual-run-1".to_string(),
translated_unix_seconds: 321,
@@ -1864,6 +1922,8 @@ mod tests {
unit_id: "unit-a".to_string(),
source_text: "source".to_string(),
translated_text: "translated".to_string(),
source_kind: TranslationTaskResultSourceKind::Provider,
translation_memory_record_id: None,
provider: "mock".to_string(),
provider_run_id: second_run.clone(),
translated_unix_seconds: 1,