// Public text helpers used by both the Lua tts API (`jarvis.speak`) and the // jarvis-app llm_fallback server-side TTS path. Kept here (not under `lua::api`) // so jarvis-app can depend on it without touching the lua sandbox internals. // Rewrite text so SAPI doesn't read every punctuation mark literally: // "J.A.R.V.I.S." → "Джарвис" // "T.O.N." / "U.S.A." → letters joined ("ТОН", "USA") // "https://google.com/foo?bar=1" → "ссылка" // "—" / "–" → "-" // collapses repeated whitespace, strips ASCII / Russian quotes. // Without this SAPI says "J ТОЧКА A ТОЧКА R ТОЧКА ..." which is unlistenable. pub fn sanitize_for_speech(text: &str) -> String { let mut t = text.to_string(); // Specific brand/product names first (caught before generic acronym rule). let replacements = [ ("J.A.R.V.I.S.", "Джарвис"), ("J.A.R.V.I.S", "Джарвис"), ("U.S.A.", "США"), ("U.K.", "Британия"), ("U.S.", "США"), ("S.O.S.", "сос"), ]; for (from, to) in replacements { t = t.replace(from, to); } // Collapse generic dotted acronyms like "T.O.N." → "TON" (3+ letters, // any alphabet) so SAPI reads the run as a single token instead of // each letter-plus-точка. t = collapse_dotted_acronyms(&t); // Strip URLs. SAPI reads them character-by-character at ~50 words per // minute; almost always the user wants a confirmation, not the URL. t = strip_urls(&t); // Soft punctuation cleanup. t = t .replace('—', " - ") .replace('–', " - ") .replace('«', "") .replace('»', "") .replace('"', ""); while t.contains(" ") { t = t.replace(" ", " "); } t.trim().to_string() } fn collapse_dotted_acronyms(s: &str) -> String { let chars: Vec = s.chars().collect(); let mut out: Vec = Vec::with_capacity(chars.len()); let mut i = 0; while i < chars.len() { let mut j = i; let mut letters: Vec = Vec::new(); loop { if j >= chars.len() { break; } if !chars[j].is_alphabetic() { break; } if j + 1 >= chars.len() || chars[j + 1] != '.' { break; } letters.push(chars[j]); j += 2; } if letters.len() >= 3 { out.extend(letters.iter()); i = j; } else { out.push(chars[i]); i += 1; } } out.into_iter().collect() } fn strip_urls(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); let mut url_buf = String::new(); let mut in_url = false; while let Some(c) = chars.next() { if !in_url { if (c == 'h' || c == 'H') && chars.clone().take(4).collect::().to_lowercase().starts_with("ttp") { in_url = true; url_buf.clear(); url_buf.push(c); continue; } out.push(c); } else if c.is_whitespace() || c == ',' || c == ';' { if url_buf.len() > 7 { out.push_str("ссылка"); } else { out.push_str(&url_buf); } out.push(c); url_buf.clear(); in_url = false; } else { url_buf.push(c); } } if in_url { if url_buf.len() > 7 { out.push_str("ссылка"); } else { out.push_str(&url_buf); } } out } #[cfg(test)] mod tests { use super::sanitize_for_speech; #[test] fn jarvis_dotted_acronym() { assert_eq!(sanitize_for_speech("Привет от J.A.R.V.I.S."), "Привет от Джарвис"); } #[test] fn generic_acronym_collapse() { assert_eq!(sanitize_for_speech("Курс T.O.N. растёт"), "Курс TON растёт"); } #[test] fn url_replaced() { let out = sanitize_for_speech("Открой https://www.google.com/search?q=x пожалуйста"); assert!(out.contains("ссылка")); assert!(!out.contains("google.com")); } #[test] fn dashes_normalised() { assert_eq!(sanitize_for_speech("Тони — гений"), "Тони - гений"); } #[test] fn passes_clean_russian_unchanged() { let s = "Привет, как дела сегодня"; assert_eq!(sanitize_for_speech(s), s); } }