| 1 | //! Post-hoc translation interception layer. |
| 2 | //! |
| 3 | //! When output translation is enabled (`/translate`), this module provides |
| 4 | //! the interception logic that detects English model output and replaces it |
| 5 | //! with Chinese translations before display. The primary mechanism is the |
| 6 | //! system prompt instruction in `prompts.rs`; this module is the fallback |
| 7 | //! for model output that leaks English despite the instruction. |
| 8 | //! |
| 9 | //! ## Architecture |
| 10 | //! |
| 11 | //! - `needs_translation()` — heuristic to detect if text is predominantly |
| 12 | //! English and should be translated. |
| 13 | //! - `translate_text()` — calls the current session model through a |
| 14 | //! shared `DeepSeekClient` to translate text to the current locale. The dedicated |
| 15 | //! translation agent receives only the source text and returns only the |
| 16 | //! translation — no tool calls, no conversation history. |
| 17 | //! - `TranslationStatus` — tracks per-message translation status in the UI. |
| 18 | |
| 19 | use anyhow::Result; |
| 20 | |
| 21 | use crate::client::DeepSeekClient; |
| 22 | |
| 23 | /// Heuristic threshold: if more than this fraction of alphabetic characters |
| 24 | /// are Latin (A-Z / a-z), the text is considered English. |
| 25 | const ENGLISH_LATIN_RATIO_THRESHOLD: f64 = 0.6; |
| 26 | |
| 27 | /// Minimum number of alphabetic characters required before applying the |
| 28 | /// heuristic — avoids false positives on short mixed-language strings. |
| 29 | const MIN_ALPHA_CHARS_FOR_DETECTION: usize = 10; |
| 30 | |
| 31 | /// How many Latin-letter "information units" each CJK character is worth. |
| 32 | /// A single CJK character carries roughly the information of a short English |
| 33 | /// word (2–4 letters), so we weight CJK at 3× for fair comparison. |
| 34 | const CJK_CHAR_WEIGHT: usize = 3; |
| 35 | |
| 36 | /// Detect if text content is predominantly English and should be translated. |
| 37 | /// |
| 38 | /// The heuristic compares CJK characters (weighted) against Latin letters. |
| 39 | /// CJK characters carry much more information per glyph, so a string with |
| 40 | /// even a modest number of Chinese characters among English words will not |
| 41 | /// be flagged. |
| 42 | #[must_use] |
| 43 | pub fn needs_translation(text: &str) -> bool { |
| 44 | let mut latin_count = 0usize; |
| 45 | let mut cjk_count = 0usize; |
| 46 | |
| 47 | for ch in text.chars() { |
| 48 | if ch.is_ascii_alphabetic() { |
| 49 | latin_count += 1; |
| 50 | } else if is_cjk(ch) { |
| 51 | cjk_count += 1; |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | let total_alpha = latin_count + (cjk_count * CJK_CHAR_WEIGHT); |
| 56 | |
| 57 | if total_alpha < MIN_ALPHA_CHARS_FOR_DETECTION { |
| 58 | return false; |
| 59 | } |
| 60 | |
| 61 | // If weighted CJK dominates, it's already Chinese — no translation needed. |
| 62 | if (cjk_count * CJK_CHAR_WEIGHT) > latin_count { |
| 63 | return false; |
| 64 | } |
| 65 | |
| 66 | let ratio = latin_count as f64 / total_alpha as f64; |
| 67 | ratio >= ENGLISH_LATIN_RATIO_THRESHOLD |
| 68 | } |
| 69 | |
| 70 | /// Check if a character is in the CJK Unified Ideographs block or is a |
| 71 | /// common Chinese/Japanese/Korean character. |
| 72 | fn is_cjk(ch: char) -> bool { |
| 73 | matches!( |
| 74 | ch, |
| 75 | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs |
| 76 | | '\u{3400}'..='\u{4DBF}' // CJK Unified Ideographs Extension A |
| 77 | | '\u{2E80}'..='\u{2EFF}' // CJK Radicals Supplement |
| 78 | | '\u{3000}'..='\u{303F}' // CJK Symbols and Punctuation |
| 79 | | '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms |
| 80 | | '\u{3040}'..='\u{309F}' // Hiragana |
| 81 | | '\u{30A0}'..='\u{30FF}' // Katakana |
| 82 | ) |
| 83 | } |
| 84 | |
| 85 | /// Translate text to the requested target language using a dedicated |
| 86 | /// translation agent. |
| 87 | /// |
| 88 | /// This is a lightweight, focused API call — no streaming, no tool calls, |
| 89 | /// no conversation history. The agent's only role is translation. |
| 90 | /// |
| 91 | /// # Errors |
| 92 | /// |
| 93 | /// Returns an error if the API call fails or the response is malformed. |
| 94 | pub async fn translate_text( |
| 95 | text: &str, |
| 96 | client: &DeepSeekClient, |
| 97 | model: &str, |
| 98 | target_language: &str, |
| 99 | ) -> Result<String> { |
| 100 | client.translate(text, model, target_language).await |
| 101 | } |
| 102 | |
| 103 | /// Status of a translation operation for a single message. |
| 104 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 105 | #[allow(dead_code)] |
| 106 | pub enum TranslationStatus { |
| 107 | /// No translation needed (already Chinese or not enough text). |
| 108 | NotNeeded, |
| 109 | /// Translation is pending — the original English is still displayed |
| 110 | /// with an indicator. |
| 111 | Pending, |
| 112 | /// Translation completed successfully. |
| 113 | Done, |
| 114 | /// Translation failed — original English displayed with fallback note. |
| 115 | Failed, |
| 116 | } |
| 117 | |
| 118 | #[cfg(test)] |
| 119 | mod tests { |
| 120 | use super::*; |
| 121 | |
| 122 | #[test] |
| 123 | fn short_text_avoids_false_positive() { |
| 124 | assert!(!needs_translation("hi")); |
| 125 | assert!(!needs_translation("ok")); |
| 126 | } |
| 127 | |
| 128 | #[test] |
| 129 | fn english_text_detected() { |
| 130 | assert!(needs_translation( |
| 131 | "This is a message from the assistant explaining how the code works." |
| 132 | )); |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn chinese_text_not_detected() { |
| 137 | assert!(!needs_translation( |
| 138 | "这是助手的一条中文回复,解释了代码的工作原理。" |
| 139 | )); |
| 140 | } |
| 141 | |
| 142 | #[test] |
| 143 | fn mixed_mostly_english_detected() { |
| 144 | assert!(needs_translation( |
| 145 | "The function handle_request takes a Request param and returns a Response." |
| 146 | )); |
| 147 | } |
| 148 | |
| 149 | #[test] |
| 150 | fn mixed_mostly_chinese_not_detected() { |
| 151 | assert!(!needs_translation( |
| 152 | "这个 handle_request 函数接收一个 Request 参数并返回 Response。" |
| 153 | )); |
| 154 | } |
| 155 | |
| 156 | #[test] |
| 157 | fn code_with_short_labels_not_falsely_detected() { |
| 158 | assert!(!needs_translation("let x = 1; let y = 2;")); |
| 159 | } |
| 160 | |
| 161 | #[test] |
| 162 | fn long_english_code_is_detected() { |
| 163 | assert!(needs_translation( |
| 164 | "function calculateTotalRevenueForQuarterlyReport() { return; }" |
| 165 | )); |
| 166 | } |
| 167 | |
| 168 | #[test] |
| 169 | fn js_comments_in_english_detected() { |
| 170 | assert!(needs_translation( |
| 171 | "// This is a JavaScript function that handles user authentication\nfunction login() {}" |
| 172 | )); |
| 173 | } |
| 174 | } |
| 175 |