| 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 | //! |
| 14 | //! The event loop dispatches focused translation requests through the exact |
| 15 | //! provider/model client frozen for the originating turn. The dedicated agent |
| 16 | //! receives only the source text and returns only the translation — no tool |
| 17 | //! calls or conversation history. |
| 18 | //! |
| 19 | |
| 20 | /// Heuristic threshold: if more than this fraction of alphabetic characters |
| 21 | /// are Latin (A-Z / a-z), the text is considered English. |
| 22 | const ENGLISH_LATIN_RATIO_THRESHOLD: f64 = 0.6; |
| 23 | |
| 24 | /// Minimum number of alphabetic characters required before applying the |
| 25 | /// heuristic — avoids false positives on short mixed-language strings. |
| 26 | const MIN_ALPHA_CHARS_FOR_DETECTION: usize = 10; |
| 27 | |
| 28 | /// How many Latin-letter "information units" each CJK character is worth. |
| 29 | /// A single CJK character carries roughly the information of a short English |
| 30 | /// word (2–4 letters), so we weight CJK at 3× for fair comparison. |
| 31 | const CJK_CHAR_WEIGHT: usize = 3; |
| 32 | |
| 33 | /// Detect if text content is predominantly English and should be translated. |
| 34 | /// |
| 35 | /// The heuristic compares CJK characters (weighted) against Latin letters. |
| 36 | /// CJK characters carry much more information per glyph, so a string with |
| 37 | /// even a modest number of Chinese characters among English words will not |
| 38 | /// be flagged. |
| 39 | #[must_use] |
| 40 | pub fn needs_translation(text: &str) -> bool { |
| 41 | let mut latin_count = 0usize; |
| 42 | let mut cjk_count = 0usize; |
| 43 | |
| 44 | for ch in text.chars() { |
| 45 | if ch.is_ascii_alphabetic() { |
| 46 | latin_count += 1; |
| 47 | } else if is_cjk(ch) { |
| 48 | cjk_count += 1; |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | let total_alpha = latin_count + (cjk_count * CJK_CHAR_WEIGHT); |
| 53 | |
| 54 | if total_alpha < MIN_ALPHA_CHARS_FOR_DETECTION { |
| 55 | return false; |
| 56 | } |
| 57 | |
| 58 | // If weighted CJK dominates, it's already Chinese — no translation needed. |
| 59 | if (cjk_count * CJK_CHAR_WEIGHT) > latin_count { |
| 60 | return false; |
| 61 | } |
| 62 | |
| 63 | let ratio = latin_count as f64 / total_alpha as f64; |
| 64 | ratio >= ENGLISH_LATIN_RATIO_THRESHOLD |
| 65 | } |
| 66 | |
| 67 | /// Check if a character is in the CJK Unified Ideographs block or is a |
| 68 | /// common Chinese/Japanese/Korean character. |
| 69 | fn is_cjk(ch: char) -> bool { |
| 70 | matches!( |
| 71 | ch, |
| 72 | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs |
| 73 | | '\u{3400}'..='\u{4DBF}' // CJK Unified Ideographs Extension A |
| 74 | | '\u{2E80}'..='\u{2EFF}' // CJK Radicals Supplement |
| 75 | | '\u{3000}'..='\u{303F}' // CJK Symbols and Punctuation |
| 76 | | '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms |
| 77 | | '\u{3040}'..='\u{309F}' // Hiragana |
| 78 | | '\u{30A0}'..='\u{30FF}' // Katakana |
| 79 | ) |
| 80 | } |
| 81 | |
| 82 | #[cfg(test)] |
| 83 | mod tests { |
| 84 | use super::*; |
| 85 | |
| 86 | #[test] |
| 87 | fn short_text_avoids_false_positive() { |
| 88 | assert!(!needs_translation("hi")); |
| 89 | assert!(!needs_translation("ok")); |
| 90 | } |
| 91 | |
| 92 | #[test] |
| 93 | fn english_text_detected() { |
| 94 | assert!(needs_translation( |
| 95 | "This is a message from the assistant explaining how the code works." |
| 96 | )); |
| 97 | } |
| 98 | |
| 99 | #[test] |
| 100 | fn chinese_text_not_detected() { |
| 101 | assert!(!needs_translation( |
| 102 | "这是助手的一条中文回复,解释了代码的工作原理。" |
| 103 | )); |
| 104 | } |
| 105 | |
| 106 | #[test] |
| 107 | fn mixed_mostly_english_detected() { |
| 108 | assert!(needs_translation( |
| 109 | "The function handle_request takes a Request param and returns a Response." |
| 110 | )); |
| 111 | } |
| 112 | |
| 113 | #[test] |
| 114 | fn mixed_mostly_chinese_not_detected() { |
| 115 | assert!(!needs_translation( |
| 116 | "这个 handle_request 函数接收一个 Request 参数并返回 Response。" |
| 117 | )); |
| 118 | } |
| 119 | |
| 120 | #[test] |
| 121 | fn code_with_short_labels_not_falsely_detected() { |
| 122 | assert!(!needs_translation("let x = 1; let y = 2;")); |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn long_english_code_is_detected() { |
| 127 | assert!(needs_translation( |
| 128 | "function calculateTotalRevenueForQuarterlyReport() { return; }" |
| 129 | )); |
| 130 | } |
| 131 | |
| 132 | #[test] |
| 133 | fn js_comments_in_english_detected() { |
| 134 | assert!(needs_translation( |
| 135 | "// This is a JavaScript function that handles user authentication\nfunction login() {}" |
| 136 | )); |
| 137 | } |
| 138 | } |
| 139 |