1use std::collections::{BTreeMap, BTreeSet};
7use std::sync::Arc;
8
9use serde_json::json;
10
11use crate::Result;
12use crate::core::formats::Subtitle;
13use crate::core::formats::manager::FormatManager;
14use crate::core::translation::CueIdGenerator;
15use crate::core::translation::request::{
16 GlossaryEntry, TerminologyMap, TranslationOutcome, TranslationRequest, TranslationResult,
17 merge_terminology,
18};
19use crate::error::SubXError;
20use crate::services::ai::AIProvider;
21use crate::services::ai::translation_prompts::{
22 TERMINOLOGY_SYSTEM_MESSAGE, TRANSLATION_SYSTEM_MESSAGE, build_terminology_prompt,
23 build_translation_prompt, is_unknown_cue_id_error, parse_terminology_response,
24 parse_translation_response_partial,
25};
26
27pub struct TranslationEngine {
35 ai_provider: Arc<dyn AIProvider>,
36 format_manager: FormatManager,
37 batch_size: usize,
38 reporter: Arc<dyn crate::core::report::Reporter>,
39}
40
41impl std::fmt::Debug for TranslationEngine {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.debug_struct("TranslationEngine")
44 .field("batch_size", &self.batch_size)
45 .finish()
46 }
47}
48
49impl TranslationEngine {
50 pub fn new(ai_provider: Arc<dyn AIProvider>, batch_size: usize) -> Result<Self> {
56 if batch_size == 0 {
57 return Err(SubXError::config(
58 "Translation batch size must be greater than 0",
59 ));
60 }
61 Ok(Self {
62 ai_provider,
63 format_manager: FormatManager::new(),
64 batch_size,
65 reporter: crate::core::report::noop(),
66 })
67 }
68
69 pub fn with_reporter(mut self, reporter: Arc<dyn crate::core::report::Reporter>) -> Self {
77 self.reporter = reporter;
78 self
79 }
80
81 fn reporter(&self) -> Arc<dyn crate::core::report::Reporter> {
83 Arc::clone(&self.reporter)
84 }
85
86 pub fn batch_size(&self) -> usize {
88 self.batch_size
89 }
90
91 pub fn format_manager(&self) -> &FormatManager {
93 &self.format_manager
94 }
95
96 pub async fn translate_subtitle(
112 &self,
113 subtitle: Subtitle,
114 request: &TranslationRequest,
115 ) -> Result<TranslationResult> {
116 if request.target_language.trim().is_empty() {
117 return Err(SubXError::config(
118 "Translation target language must be provided",
119 ));
120 }
121
122 let mut subtitle = subtitle;
123 if subtitle.entries.is_empty() {
124 return Ok(TranslationResult {
125 subtitle,
126 outcome: TranslationOutcome::default(),
127 });
128 }
129
130 let mut id_gen = CueIdGenerator::new();
132 let cue_ids: Vec<String> = subtitle
133 .entries
134 .iter()
135 .map(|_| id_gen.next_id().to_string())
136 .collect();
137 let protected_cues: Vec<ProtectedCueText> = subtitle
138 .entries
139 .iter()
140 .enumerate()
141 .map(|(idx, entry)| protect_inline_formatting(&entry.text, idx))
142 .collect();
143 let terminology_texts: Vec<String> = protected_cues
144 .iter()
145 .map(|cue| cue.visible_text.clone())
146 .collect();
147
148 let generated_terms = self
150 .extract_terminology(&terminology_texts, request)
151 .await?;
152 let effective_terminology = merge_terminology(generated_terms, &request.glossary_entries);
153
154 let mut translations: BTreeMap<String, String> = BTreeMap::new();
157 let mut batch_count = 0usize;
158 for chunk_indices in chunk_ranges(subtitle.entries.len(), self.batch_size) {
159 let mut batch_cues: Vec<(String, String)> = Vec::with_capacity(chunk_indices.len());
160 let mut batch_ids: Vec<String> = Vec::with_capacity(chunk_indices.len());
161 for &i in &chunk_indices {
162 batch_cues.push((cue_ids[i].clone(), protected_cues[i].prompt_text.clone()));
163 batch_ids.push(cue_ids[i].clone());
164 }
165
166 let (map, issued_batches) = self
167 .translate_batch_with_unknown_retry(
168 &batch_cues,
169 &batch_ids,
170 request,
171 &effective_terminology,
172 )
173 .await?;
174 for (id, text) in map {
175 translations.insert(id, text);
176 }
177 batch_count += issued_batches;
178 self.reporter()
179 .progress(&crate::core::report::ProgressEvent::Message(
180 &format_translation_progress(translations.len(), cue_ids.len()),
181 ));
182 }
183
184 let mut empty_fallback_ids = BTreeSet::new();
185 let missing_after_initial = missing_translation_indices(&cue_ids, &translations);
186 if !missing_after_initial.is_empty() {
187 let (retry_map, issued_batches) = self
188 .retry_missing_translations(
189 &cue_ids,
190 &protected_cues,
191 &missing_after_initial,
192 request,
193 &effective_terminology,
194 )
195 .await?;
196 for (id, text) in retry_map {
197 translations.insert(id, text);
198 }
199 batch_count += issued_batches;
200
201 for idx in missing_translation_indices(&cue_ids, &translations) {
202 let id = cue_ids[idx].clone();
203 translations.insert(id.clone(), String::new());
204 empty_fallback_ids.insert(id);
205 }
206 self.reporter()
207 .progress(&crate::core::report::ProgressEvent::Message(
208 &format_translation_progress(translations.len(), cue_ids.len()),
209 ));
210 }
211
212 for ((entry, id), protected) in subtitle
215 .entries
216 .iter_mut()
217 .zip(cue_ids.iter())
218 .zip(protected_cues.iter())
219 {
220 if let Some(translated) = translations.get(id) {
221 if empty_fallback_ids.contains(id) {
222 entry.text = String::new();
223 } else {
224 entry.text = restore_inline_formatting(translated, protected)?;
225 }
226 }
227 }
228
229 let translated_cue_count = subtitle.entries.len();
230 Ok(TranslationResult {
231 subtitle,
232 outcome: TranslationOutcome {
233 effective_terminology,
234 translated_cue_count,
235 batch_count,
236 },
237 })
238 }
239
240 pub async fn translate_content(
246 &self,
247 content: &str,
248 request: &TranslationRequest,
249 ) -> Result<TranslationResult> {
250 let subtitle = self.format_manager.parse_auto(content)?;
251 self.translate_subtitle(subtitle, request).await
252 }
253
254 pub async fn extract_terminology(
259 &self,
260 cue_texts: &[String],
261 request: &TranslationRequest,
262 ) -> Result<TerminologyMap> {
263 let prompt = build_terminology_prompt(
264 &request.target_language,
265 request.source_language.as_deref(),
266 cue_texts,
267 request.glossary_text.as_deref(),
268 request.context.as_deref(),
269 );
270 let messages = vec![
271 json!({"role": "system", "content": TERMINOLOGY_SYSTEM_MESSAGE}),
272 json!({"role": "user", "content": prompt}),
273 ];
274 let response = self.ai_provider.chat_completion(messages).await?;
275 parse_terminology_response(&response)
276 }
277
278 async fn retry_missing_translations(
279 &self,
280 cue_ids: &[String],
281 protected_cues: &[ProtectedCueText],
282 missing_indices: &[usize],
283 request: &TranslationRequest,
284 terminology: &TerminologyMap,
285 ) -> Result<(BTreeMap<String, String>, usize)> {
286 let mut retry_cues = Vec::with_capacity(missing_indices.len());
287 let mut retry_ids = Vec::with_capacity(missing_indices.len());
288 for &idx in missing_indices {
289 retry_cues.push((
290 cue_ids[idx].clone(),
291 protected_cues[idx].prompt_text.clone(),
292 ));
293 retry_ids.push(cue_ids[idx].clone());
294 }
295
296 self.translate_batch_with_unknown_retry(&retry_cues, &retry_ids, request, terminology)
297 .await
298 }
299
300 async fn translate_batch_with_unknown_retry(
301 &self,
302 batch_cues: &[(String, String)],
303 batch_ids: &[String],
304 request: &TranslationRequest,
305 terminology: &TerminologyMap,
306 ) -> Result<(BTreeMap<String, String>, usize)> {
307 match self
308 .translate_batch_once(batch_cues, batch_ids, request, terminology)
309 .await
310 {
311 Ok(map) => Ok((map, 1)),
312 Err(err) if is_unknown_cue_id_error(&err) => {
313 self.reporter().progress(&crate::core::report::ProgressEvent::Message(
316 "⚠ Translation response contained an unknown cue ID; discarding the batch response and retrying once.",
317 ));
318 match self
319 .translate_batch_once(batch_cues, batch_ids, request, terminology)
320 .await
321 {
322 Ok(map) => Ok((map, 2)),
323 Err(retry_err) if is_unknown_cue_id_error(&retry_err) => {
324 Err(SubXError::ai_service(format!(
325 "Translation response still contained an unknown cue ID after retry; failing this file: {retry_err}"
326 )))
327 }
328 Err(retry_err) => Err(retry_err),
329 }
330 }
331 Err(err) => Err(err),
332 }
333 }
334
335 async fn translate_batch_once(
336 &self,
337 batch_cues: &[(String, String)],
338 batch_ids: &[String],
339 request: &TranslationRequest,
340 terminology: &TerminologyMap,
341 ) -> Result<BTreeMap<String, String>> {
342 let prompt = build_translation_prompt(
343 &request.target_language,
344 request.source_language.as_deref(),
345 terminology,
346 request.glossary_text.as_deref(),
347 request.context.as_deref(),
348 batch_cues,
349 );
350 let messages = vec![
351 json!({"role": "system", "content": TRANSLATION_SYSTEM_MESSAGE}),
352 json!({"role": "user", "content": prompt}),
353 ];
354 let response = self.ai_provider.chat_completion(messages).await?;
355 Ok(parse_translation_response_partial(&response, batch_ids)?
356 .into_iter()
357 .collect())
358 }
359}
360
361pub fn parse_glossary_text(text: &str) -> Vec<GlossaryEntry> {
368 let mut out = Vec::new();
369 for raw_line in text.lines() {
370 let line = raw_line.trim();
371 if line.is_empty() || line.starts_with('#') {
372 continue;
373 }
374 let separator = if line.contains("->") {
375 "->"
376 } else if line.contains('=') {
377 "="
378 } else {
379 continue;
380 };
381 let mut parts = line.splitn(2, separator);
382 let source = parts.next().map(str::trim).unwrap_or("").to_string();
383 let target = parts.next().map(str::trim).unwrap_or("").to_string();
384 if source.is_empty() || target.is_empty() {
385 continue;
386 }
387 out.push(GlossaryEntry { source, target });
388 }
389 out
390}
391
392fn chunk_ranges(total: usize, batch_size: usize) -> Vec<Vec<usize>> {
393 let mut chunks = Vec::new();
394 let mut start = 0;
395 while start < total {
396 let end = (start + batch_size).min(total);
397 chunks.push((start..end).collect());
398 start = end;
399 }
400 chunks
401}
402
403fn missing_translation_indices(
404 cue_ids: &[String],
405 translations: &BTreeMap<String, String>,
406) -> Vec<usize> {
407 cue_ids
408 .iter()
409 .enumerate()
410 .filter_map(|(idx, id)| (!translations.contains_key(id)).then_some(idx))
411 .collect()
412}
413
414fn format_translation_progress(processed_cues: usize, total_cues: usize) -> String {
415 format!("📊 Translation Progress:\n Processed cues: {processed_cues}/{total_cues}")
416}
417
418#[derive(Debug, Clone)]
419struct ProtectedCueText {
420 prompt_text: String,
421 visible_text: String,
422 markers: Vec<(String, String)>,
423}
424
425fn protect_inline_formatting(text: &str, cue_index: usize) -> ProtectedCueText {
426 let mut prompt_text = String::new();
427 let mut visible_text = String::new();
428 let mut markers = Vec::new();
429 let mut offset = 0usize;
430
431 while offset < text.len() {
432 let remaining = &text[offset..];
433
434 if let Some(end_offset) = html_like_tag_end(remaining) {
435 let token = &text[offset..offset + end_offset];
436 push_format_marker(cue_index, token, &mut prompt_text, &mut markers);
437 offset += end_offset;
438 continue;
439 }
440
441 if let Some(end_offset) = ass_override_tag_end(remaining) {
442 let token = &text[offset..offset + end_offset];
443 push_format_marker(cue_index, token, &mut prompt_text, &mut markers);
444 offset += end_offset;
445 continue;
446 }
447
448 let ch = remaining
449 .chars()
450 .next()
451 .expect("offset is always inside a non-empty string slice");
452 prompt_text.push(ch);
453 visible_text.push(ch);
454 offset += ch.len_utf8();
455 }
456
457 ProtectedCueText {
458 prompt_text,
459 visible_text,
460 markers,
461 }
462}
463
464fn html_like_tag_end(text: &str) -> Option<usize> {
465 if !text.starts_with('<') {
466 return None;
467 }
468 let end = text.find('>')? + 1;
469 (end > 2).then_some(end)
470}
471
472fn ass_override_tag_end(text: &str) -> Option<usize> {
473 if !text.starts_with('{') {
474 return None;
475 }
476 let end = text.find('}')? + 1;
477 let token = &text[..end];
478 token.contains('\\').then_some(end)
479}
480
481fn push_format_marker(
482 cue_index: usize,
483 token: &str,
484 prompt_text: &mut String,
485 markers: &mut Vec<(String, String)>,
486) {
487 let placeholder = format!("__SUBX_FMT_{}_{}__", cue_index, markers.len());
488 prompt_text.push_str(&placeholder);
489 markers.push((placeholder, token.to_string()));
490}
491
492fn restore_inline_formatting(translated: &str, protected: &ProtectedCueText) -> Result<String> {
493 let mut restored = translated.to_string();
494 for (placeholder, token) in &protected.markers {
495 let count = restored.matches(placeholder).count();
496 if count != 1 {
497 return Err(SubXError::ai_service(format!(
498 "Translation response must preserve formatting placeholder {placeholder} exactly once"
499 )));
500 }
501 restored = restored.replace(placeholder, token);
502 }
503 Ok(restored)
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use async_trait::async_trait;
510 use std::sync::Mutex;
511 use std::time::Duration;
512
513 use crate::core::formats::{Subtitle, SubtitleEntry, SubtitleFormatType, SubtitleMetadata};
514 use crate::services::ai::{
515 AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
516 };
517
518 struct ScriptedAI {
519 responses: Mutex<Vec<String>>,
520 }
521
522 impl ScriptedAI {
523 fn new(responses: Vec<&str>) -> Arc<Self> {
524 Arc::new(Self {
525 responses: Mutex::new(responses.into_iter().map(|s| s.to_string()).collect()),
526 })
527 }
528 }
529
530 #[async_trait]
531 impl AIProvider for ScriptedAI {
532 async fn analyze_content(&self, _r: AnalysisRequest) -> Result<MatchResult> {
533 unreachable!()
534 }
535
536 async fn verify_match(&self, _r: VerificationRequest) -> Result<ConfidenceScore> {
537 unreachable!()
538 }
539
540 async fn chat_completion(&self, _messages: Vec<serde_json::Value>) -> Result<String> {
541 let mut responses = self.responses.lock().unwrap();
542 if responses.is_empty() {
543 return Err(SubXError::ai_service("no scripted response left"));
544 }
545 Ok(responses.remove(0))
546 }
547 }
548
549 fn sample_subtitle() -> Subtitle {
550 let metadata = SubtitleMetadata::new(SubtitleFormatType::Srt);
551 let mut sub = Subtitle::new(SubtitleFormatType::Srt, metadata);
552 sub.entries.push(SubtitleEntry::new(
553 1,
554 Duration::from_secs(1),
555 Duration::from_secs(2),
556 "Hello Alice".to_string(),
557 ));
558 sub.entries.push(SubtitleEntry::new(
559 2,
560 Duration::from_secs(3),
561 Duration::from_secs(4),
562 "Goodbye Alice".to_string(),
563 ));
564 sub
565 }
566
567 #[tokio::test]
568 async fn translation_engine_translates_in_order() {
569 let term_resp = r#"{"terms":[{"source":"Alice","target":"愛麗絲"}]}"#;
570 let cues_resp = r#"{"translations":[{"id":"__ID0__","text":"哈囉 愛麗絲"},{"id":"__ID1__","text":"再見 愛麗絲"}]}"#;
572 let provider = ScriptedAI::new(vec![term_resp, cues_resp]);
573
574 struct PlaceholderAI {
577 inner: Arc<ScriptedAI>,
578 captured_ids: Mutex<Vec<String>>,
579 }
580 #[async_trait]
581 impl AIProvider for PlaceholderAI {
582 async fn analyze_content(&self, _r: AnalysisRequest) -> Result<MatchResult> {
583 unreachable!()
584 }
585 async fn verify_match(&self, _r: VerificationRequest) -> Result<ConfidenceScore> {
586 unreachable!()
587 }
588 async fn chat_completion(&self, messages: Vec<serde_json::Value>) -> Result<String> {
589 let prompt = messages
592 .last()
593 .and_then(|m| m.get("content"))
594 .and_then(|c| c.as_str())
595 .unwrap_or("")
596 .to_string();
597 let mut response = self.inner.chat_completion(messages).await?;
598 if response.contains("__ID0__") {
599 let ids: Vec<String> = prompt
600 .lines()
601 .filter_map(|l| l.trim().strip_prefix("- id: "))
602 .map(|s| s.trim().to_string())
603 .collect();
604 let mut captured = self.captured_ids.lock().unwrap();
605 *captured = ids.clone();
606 for (i, id) in ids.iter().enumerate() {
607 response = response.replace(&format!("__ID{}__", i), id);
608 }
609 }
610 Ok(response)
611 }
612 }
613
614 let provider: Arc<dyn AIProvider> = Arc::new(PlaceholderAI {
615 inner: provider,
616 captured_ids: Mutex::new(Vec::new()),
617 });
618 let engine = TranslationEngine::new(provider, 10).unwrap();
619 let request = TranslationRequest {
620 target_language: "zh-TW".to_string(),
621 source_language: Some("en".to_string()),
622 glossary_text: None,
623 context: None,
624 glossary_entries: vec![],
625 };
626 let result = engine
627 .translate_subtitle(sample_subtitle(), &request)
628 .await
629 .unwrap();
630 assert_eq!(result.subtitle.entries.len(), 2);
631 assert_eq!(result.subtitle.entries[0].text, "哈囉 愛麗絲");
632 assert_eq!(result.subtitle.entries[1].text, "再見 愛麗絲");
633 assert_eq!(result.outcome.translated_cue_count, 2);
634 assert_eq!(result.outcome.batch_count, 1);
635 assert_eq!(
636 result.outcome.effective_terminology.get("Alice").unwrap(),
637 "愛麗絲"
638 );
639 assert_eq!(
641 result.subtitle.entries[0].start_time,
642 Duration::from_secs(1)
643 );
644 assert_eq!(result.subtitle.entries[1].end_time, Duration::from_secs(4));
645 }
646
647 #[tokio::test]
648 async fn empty_subtitle_returns_empty_outcome() {
649 let provider: Arc<dyn AIProvider> = ScriptedAI::new(vec![]);
650 let engine = TranslationEngine::new(provider, 5).unwrap();
651 let metadata = SubtitleMetadata::new(SubtitleFormatType::Srt);
652 let sub = Subtitle::new(SubtitleFormatType::Srt, metadata);
653 let request = TranslationRequest {
654 target_language: "zh-TW".to_string(),
655 source_language: None,
656 glossary_text: None,
657 context: None,
658 glossary_entries: vec![],
659 };
660 let result = engine.translate_subtitle(sub, &request).await.unwrap();
661 assert_eq!(result.outcome.translated_cue_count, 0);
662 assert_eq!(result.outcome.batch_count, 0);
663 }
664
665 #[test]
666 fn batch_size_zero_is_rejected() {
667 let provider: Arc<dyn AIProvider> = ScriptedAI::new(vec![]);
668 let err = TranslationEngine::new(provider, 0).unwrap_err();
669 assert!(err.to_string().contains("batch size"));
670 }
671
672 #[test]
673 fn parse_glossary_text_handles_multiple_separators() {
674 let text = "# comment\nAlice = 艾莉絲\nBob -> 鮑伯\n\n";
675 let entries = parse_glossary_text(text);
676 assert_eq!(entries.len(), 2);
677 assert_eq!(entries[0].source, "Alice");
678 assert_eq!(entries[0].target, "艾莉絲");
679 assert_eq!(entries[1].source, "Bob");
680 assert_eq!(entries[1].target, "鮑伯");
681 }
682
683 #[test]
684 fn protect_and_restore_inline_formatting_tokens() {
685 let protected = protect_inline_formatting(r#"<i>{\b1}Hello{\b0}</i>"#, 3);
686 assert_eq!(
687 protected.prompt_text,
688 "__SUBX_FMT_3_0____SUBX_FMT_3_1__Hello__SUBX_FMT_3_2____SUBX_FMT_3_3__"
689 );
690 assert_eq!(protected.visible_text, "Hello");
691
692 let translated = "__SUBX_FMT_3_0____SUBX_FMT_3_1__你好__SUBX_FMT_3_2____SUBX_FMT_3_3__";
693 let restored = restore_inline_formatting(translated, &protected).unwrap();
694 assert_eq!(restored, r#"<i>{\b1}你好{\b0}</i>"#);
695 }
696
697 #[test]
698 fn translation_progress_message_includes_processed_and_total_cues() {
699 assert_eq!(
700 format_translation_progress(42, 100),
701 "📊 Translation Progress:\n Processed cues: 42/100"
702 );
703 }
704}