use async_trait::async_trait;
use crate::types::{ContextSnapshot, Span};
#[async_trait]
pub trait TextProcessor: Send + Sync {
async fn process(
&self,
text: &str,
context: &ContextSnapshot,
) -> Result<ProcessResult, ProcessError>;
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct ProcessResult {
pub text: String,
pub corrections: Vec<Correction>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Correction {
pub kind: CorrectionKind,
pub original: String,
pub replacement: String,
pub span: Option<Span>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CorrectionKind {
PunctuationInserted,
Capitalized,
SelfCorrectionRemoved,
ListFormatted,
DictionaryMatch,
NumeralNormalized,
SpokenFormNormalized,
EntityDetected,
}
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum ProcessError {
#[error("processor resource unavailable: {0}")]
Unavailable(String),
#[error("processor inference failed: {0}")]
Inference(String),
#[error("processor failed: {0}")]
Failed(String),
}
pub struct SelfCorrectionDetector {
min_shared_words: usize,
correction_cues_en: Vec<String>,
correction_cues_ja: Vec<String>,
correction_cues_es: Vec<String>,
correction_cues_zh: Vec<String>,
correction_cues_ko: Vec<String>,
}
impl SelfCorrectionDetector {
pub fn new() -> Self {
let mut correction_cues_en: Vec<String> = vec![
"no",
"wait",
"sorry",
"i mean",
"actually",
"rather",
"no wait",
"or rather",
]
.into_iter()
.map(String::from)
.collect();
correction_cues_en.sort_by_key(|c| std::cmp::Reverse(c.chars().count()));
let mut correction_cues_ja: Vec<String> = vec![
"いや",
"じゃなくて",
"じゃなく",
"ではなく",
"ていうか",
"っていうか",
"じゃない",
]
.into_iter()
.map(String::from)
.collect();
correction_cues_ja.sort_by_key(|c| std::cmp::Reverse(c.chars().count()));
let mut correction_cues_es: Vec<String> = vec![
"mejor dicho",
"quiero decir",
"o sea",
"perdón",
"mejor",
"digo",
"no es",
"no",
]
.into_iter()
.map(String::from)
.collect();
correction_cues_es.sort_by_key(|c| std::cmp::Reverse(c.chars().count()));
let mut correction_cues_zh: Vec<String> = vec![
"我的意思是",
"确切地说",
"应该说",
"我是说",
"不对",
"不是",
"算了",
]
.into_iter()
.map(String::from)
.collect();
correction_cues_zh.sort_by_key(|c| std::cmp::Reverse(c.chars().count()));
let mut correction_cues_ko: Vec<String> = vec![
"그게 아니라",
"그게 아니고",
"잘못 말했다",
"잘못 말했네",
"아 잠깐",
"잠깐만",
"아니에요",
"아니라",
"아니야",
"아니",
]
.into_iter()
.map(String::from)
.collect();
correction_cues_ko.sort_by_key(|c| std::cmp::Reverse(c.chars().count()));
Self {
min_shared_words: 1,
correction_cues_en,
correction_cues_ja,
correction_cues_es,
correction_cues_zh,
correction_cues_ko,
}
}
fn detect_english(&self, text: &str) -> Option<(String, Correction)> {
let lower = text.to_lowercase();
for cue in &self.correction_cues_en {
if let Some(cue_pos) = lower.find(cue.as_str()) {
let before_cue = &text[..cue_pos].trim_end();
let after_cue = &text[cue_pos + cue.len()..].trim_start();
if after_cue.is_empty() {
continue;
}
let before_words: Vec<&str> = before_cue.split_whitespace().collect();
let after_words: Vec<&str> = after_cue.split_whitespace().collect();
if after_words.is_empty() {
continue;
}
let shared = Self::count_shared_prefix_from_end(&before_words, &after_words);
if shared >= self.min_shared_words {
let keep_count = before_words.len() - shared;
let kept: Vec<&str> = before_words[..keep_count].to_vec();
let result = if kept.is_empty() {
after_cue.to_string()
} else {
format!("{} {}", kept.join(" "), after_cue)
};
let original_rm = before_words[keep_count..].join(" ");
return Some((
result,
Correction {
kind: CorrectionKind::SelfCorrectionRemoved,
original: format!("{} {}", original_rm, cue),
replacement: String::new(),
span: None,
},
));
}
}
}
None
}
fn detect_japanese(&self, text: &str) -> Option<(String, Correction)> {
for cue in &self.correction_cues_ja {
if let Some(cue_pos) = text.find(cue.as_str()) {
let before = text[..cue_pos].trim_end_matches('、').trim_end();
let after = text[cue_pos + cue.len()..]
.trim_start_matches('、')
.trim_start();
if after.is_empty() || before.is_empty() {
continue;
}
let segments: Vec<&str> = before.split('、').collect();
if segments.is_empty() {
continue;
}
let reparandum = segments.last().unwrap().trim();
let kept_before = if segments.len() > 1 {
segments[..segments.len() - 1].join("、")
} else {
String::new()
};
let result = if kept_before.is_empty() {
after.to_string()
} else {
format!("{}、{}", kept_before, after)
};
return Some((
result,
Correction {
kind: CorrectionKind::SelfCorrectionRemoved,
original: format!("{}{}", reparandum, cue),
replacement: String::new(),
span: None,
},
));
}
}
None
}
fn detect_spanish(&self, text: &str) -> Option<(String, Correction)> {
let lower = text.to_lowercase();
for cue in &self.correction_cues_es {
let mut from = 0usize;
while let Some(rel) = lower[from..].find(cue.as_str()) {
let cue_pos = from + rel;
let cue_end = cue_pos + cue.len();
let left_ok = cue_pos == 0
|| !lower
.as_bytes()
.get(cue_pos - 1)
.map(|b| b.is_ascii_alphabetic())
.unwrap_or(false);
let right_ok = cue_end >= lower.len()
|| !lower
.as_bytes()
.get(cue_end)
.map(|b| b.is_ascii_alphabetic())
.unwrap_or(false);
if !(left_ok && right_ok) {
from = cue_pos + cue.chars().next().map_or(1, |c| c.len_utf8());
continue;
}
let trim_chars: &[char] = &[',', '.', ';', ':', '!', '?', ' ', '\t'];
let before_cue = text[..cue_pos].trim_end_matches(trim_chars);
let after_cue = text[cue_end..].trim_start_matches(trim_chars);
if after_cue.is_empty() || before_cue.is_empty() {
from = cue_end;
continue;
}
let before_words: Vec<&str> = before_cue.split_whitespace().collect();
let after_words: Vec<&str> = after_cue.split_whitespace().collect();
if after_words.is_empty() || before_words.is_empty() {
from = cue_end;
continue;
}
let shared = Self::count_shared_prefix_from_end(&before_words, &after_words);
if shared >= self.min_shared_words {
let keep_count = before_words.len() - shared;
let kept: Vec<&str> = before_words[..keep_count].to_vec();
let result = if kept.is_empty() {
after_cue.to_string()
} else {
format!("{} {}", kept.join(" "), after_cue)
};
let original_rm = before_words[keep_count..].join(" ");
return Some((
result,
Correction {
kind: CorrectionKind::SelfCorrectionRemoved,
original: format!("{} {}", original_rm, cue),
replacement: String::new(),
span: None,
},
));
}
from = cue_end;
}
}
None
}
fn detect_chinese(&self, text: &str) -> Option<(String, Correction)> {
for cue in &self.correction_cues_zh {
if let Some(cue_pos) = text.find(cue.as_str()) {
let trim_clause: &[char] = &['、', ',', ',', ' '];
let before = text[..cue_pos].trim_end_matches(trim_clause).trim_end();
let after = text[cue_pos + cue.len()..]
.trim_start_matches(trim_clause)
.trim_start();
if after.is_empty() || before.is_empty() {
continue;
}
let segments: Vec<&str> = before.split(['、', ',']).collect();
if segments.is_empty() {
continue;
}
let reparandum = segments.last().unwrap().trim();
let kept_before = if segments.len() > 1 {
segments[..segments.len() - 1].join(",")
} else {
String::new()
};
let result = if kept_before.is_empty() {
after.to_string()
} else {
format!("{},{}", kept_before, after)
};
return Some((
result,
Correction {
kind: CorrectionKind::SelfCorrectionRemoved,
original: format!("{}{}", reparandum, cue),
replacement: String::new(),
span: None,
},
));
}
}
None
}
fn detect_korean(&self, text: &str) -> Option<(String, Correction)> {
for cue in &self.correction_cues_ko {
let mut from = 0usize;
while let Some(rel) = text[from..].find(cue.as_str()) {
let cue_pos = from + rel;
let cue_end = cue_pos + cue.len();
let left_ok = cue_pos == 0
|| text[..cue_pos]
.chars()
.last()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
let right_ok = cue_end == text.len()
|| text[cue_end..]
.chars()
.next()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
if !(left_ok && right_ok) {
from = cue_pos + cue.chars().next().map_or(1, |c| c.len_utf8());
continue;
}
let trim_chars: &[char] = &[',', '.', ';', ':', '!', '?', ' ', '\t'];
let before_cue = text[..cue_pos].trim_end_matches(trim_chars);
let after_cue = text[cue_end..].trim_start_matches(trim_chars);
if after_cue.is_empty() || before_cue.is_empty() {
from = cue_end;
continue;
}
let segments: Vec<&str> = before_cue.split([',', '.', '!', '?', ';']).collect();
if segments.is_empty() {
from = cue_end;
continue;
}
let reparandum = segments.last().unwrap().trim();
let kept_before = if segments.len() > 1 {
segments[..segments.len() - 1].join(",")
} else {
String::new()
};
let result = if kept_before.is_empty() {
after_cue.to_string()
} else {
format!("{}, {}", kept_before.trim_end(), after_cue)
};
return Some((
result,
Correction {
kind: CorrectionKind::SelfCorrectionRemoved,
original: format!("{} {}", reparandum, cue),
replacement: String::new(),
span: None,
},
));
}
}
None
}
fn count_shared_prefix_from_end(before: &[&str], after: &[&str]) -> usize {
let mut count = 0;
let max_check = before.len().min(after.len()).min(5);
for offset in 1..=max_check {
let b_idx = before.len() - offset;
if before[b_idx].to_lowercase() == after[0].to_lowercase() {
let mut matches = 1;
for j in 1..offset.min(after.len()) {
if before[b_idx + j].to_lowercase() == after[j].to_lowercase() {
matches += 1;
} else {
break;
}
}
if matches >= 1 {
count = count.max(offset);
}
}
}
count
}
}
impl Default for SelfCorrectionDetector {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl TextProcessor for SelfCorrectionDetector {
async fn process(
&self,
text: &str,
_context: &ContextSnapshot,
) -> Result<ProcessResult, ProcessError> {
if let Some((result, correction)) = self.detect_korean(text) {
return Ok(ProcessResult {
text: result,
corrections: vec![correction],
});
}
if let Some((result, correction)) = self.detect_chinese(text) {
return Ok(ProcessResult {
text: result,
corrections: vec![correction],
});
}
if let Some((result, correction)) = self.detect_japanese(text) {
return Ok(ProcessResult {
text: result,
corrections: vec![correction],
});
}
if let Some((result, correction)) = self.detect_spanish(text) {
return Ok(ProcessResult {
text: result,
corrections: vec![correction],
});
}
if let Some((result, correction)) = self.detect_english(text) {
return Ok(ProcessResult {
text: result,
corrections: vec![correction],
});
}
Ok(ProcessResult {
text: text.to_string(),
corrections: vec![],
})
}
}
pub struct BasicPunctuationRestorer;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DominantScript {
Latin,
Hangul,
Hanzi,
Kana,
Other,
}
fn classify_script(text: &str) -> DominantScript {
let mut latin = 0usize;
let mut hangul = 0usize;
let mut hanzi = 0usize;
let mut kana = 0usize;
for c in text.chars() {
if c.is_ascii_alphabetic() {
latin += 1;
} else if matches!(c as u32, 0xAC00..=0xD7A3 | 0x1100..=0x11FF | 0x3130..=0x318F) {
hangul += 1;
} else if matches!(c as u32, 0x4E00..=0x9FFF | 0x3400..=0x4DBF) {
hanzi += 1;
} else if matches!(c as u32, 0x3040..=0x30FF) {
kana += 1;
}
}
if kana > 0 && kana + hanzi >= latin && kana + hanzi >= hangul {
return DominantScript::Kana;
}
if hangul > latin && hangul > hanzi {
return DominantScript::Hangul;
}
if hanzi > latin && hanzi > hangul {
return DominantScript::Hanzi;
}
if latin > 0 {
return DominantScript::Latin;
}
DominantScript::Other
}
#[async_trait]
impl TextProcessor for BasicPunctuationRestorer {
async fn process(
&self,
text: &str,
_context: &ContextSnapshot,
) -> Result<ProcessResult, ProcessError> {
if text.is_empty() {
return Ok(ProcessResult {
text: String::new(),
corrections: vec![],
});
}
let script = classify_script(text);
let capitalise = matches!(script, DominantScript::Latin);
let mut result = String::with_capacity(text.len() + 8);
let mut corrections = Vec::new();
let mut capitalize_next = capitalise;
for ch in text.chars() {
if capitalize_next && ch.is_alphabetic() {
let upper: String = ch.to_uppercase().collect();
if upper != ch.to_string() {
corrections.push(Correction {
kind: CorrectionKind::Capitalized,
original: ch.to_string(),
replacement: upper.clone(),
span: None,
});
}
result.push_str(&upper);
capitalize_next = false;
} else {
result.push(ch);
if capitalise && (ch == '.' || ch == '!' || ch == '?') {
capitalize_next = true;
} else if ch == '。' {
capitalize_next = false; }
}
}
let trimmed = result.trim_end();
let last_char = trimmed.chars().last();
let want_terminal = match script {
DominantScript::Latin | DominantScript::Hangul => Some('.'),
DominantScript::Hanzi | DominantScript::Kana => Some('。'),
DominantScript::Other => None,
};
if let Some(terminal) = want_terminal {
if let Some(last) = last_char {
let already_terminated = match terminal {
'.' => matches!(last, '.' | '!' | '?' | ')' | '"' | '\'' | '。'),
'。' => matches!(last, '。' | '!' | '?' | '.' | '!' | '?'),
_ => true,
};
if !already_terminated {
result = format!("{}{}", trimmed, terminal);
corrections.push(Correction {
kind: CorrectionKind::PunctuationInserted,
original: String::new(),
replacement: terminal.to_string(),
span: None,
});
}
}
}
Ok(ProcessResult {
text: result,
corrections,
})
}
}
pub struct InverseTextNormalizer {
backend: ItnBackend,
}
enum ItnBackend {
EnglishSentence,
Lang(&'static str),
Passthrough,
}
impl InverseTextNormalizer {
pub fn new(lang: &str) -> Self {
let backend = match lang {
"en" => ItnBackend::EnglishSentence,
"ja" => ItnBackend::Lang("ja"),
"zh" => ItnBackend::Lang("zh"),
_ => ItnBackend::Passthrough,
};
Self { backend }
}
}
#[async_trait]
impl TextProcessor for InverseTextNormalizer {
async fn process(
&self,
text: &str,
_context: &ContextSnapshot,
) -> Result<ProcessResult, ProcessError> {
let normalized = match self.backend {
ItnBackend::EnglishSentence => text_processing_rs::normalize_sentence(text),
ItnBackend::Lang(lang) => text_processing_rs::normalize_with_lang(text, lang),
ItnBackend::Passthrough => {
return Ok(ProcessResult {
text: text.to_string(),
corrections: vec![],
});
}
};
let corrections = if normalized != text {
vec![Correction {
kind: CorrectionKind::NumeralNormalized,
original: text.to_string(),
replacement: normalized.clone(),
span: None,
}]
} else {
vec![]
};
Ok(ProcessResult {
text: normalized,
corrections,
})
}
}
pub struct SpokenFormNormalizer {
lookup: Option<fn(&str) -> Option<&'static str>>,
}
impl SpokenFormNormalizer {
pub fn new(lang: &str) -> Self {
let lookup = match lang {
"en" => Some(en_spoken_form as fn(&str) -> Option<&'static str>),
_ => None,
};
Self { lookup }
}
}
fn en_spoken_form(token: &str) -> Option<&'static str> {
match token {
"gonna" => Some("going to"),
"wanna" => Some("want to"),
"gotta" => Some("got to"),
"hafta" => Some("have to"),
"oughta" => Some("ought to"),
"tryna" => Some("trying to"),
"gimme" => Some("give me"),
"lemme" => Some("let me"),
"kinda" => Some("kind of"),
"sorta" => Some("sort of"),
"outta" => Some("out of"),
"lotta" => Some("lot of"),
"dunno" => Some("don't know"),
"c'mon" => Some("come on"),
"'cause" => Some("because"),
"cuz" => Some("because"),
"y'all" => Some("you all"),
_ => None,
}
}
fn match_leading_case(original: &str, replacement: &str) -> String {
if original.chars().next().is_some_and(|c| c.is_uppercase()) {
let mut chars = replacement.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
} else {
replacement.to_string()
}
}
#[async_trait]
impl TextProcessor for SpokenFormNormalizer {
async fn process(
&self,
text: &str,
_context: &ContextSnapshot,
) -> Result<ProcessResult, ProcessError> {
let Some(lookup) = self.lookup else {
return Ok(ProcessResult {
text: text.to_string(),
corrections: vec![],
});
};
let mut out: Vec<String> = Vec::new();
let mut corrections = Vec::new();
for token in text.split_whitespace() {
let trail_start = token
.char_indices()
.rev()
.take_while(|(_, c)| matches!(c, '.' | ',' | '!' | '?' | ';' | ':'))
.last()
.map(|(i, _)| i)
.unwrap_or(token.len());
let (core, trailing) = token.split_at(trail_start);
let lower = core.to_lowercase();
match lookup(&lower) {
Some(replacement) if !core.is_empty() => {
let cased = match_leading_case(core, replacement);
corrections.push(Correction {
kind: CorrectionKind::SpokenFormNormalized,
original: core.to_string(),
replacement: cased.clone(),
span: None,
});
out.push(format!("{}{}", cased, trailing));
}
_ => out.push(token.to_string()),
}
}
if corrections.is_empty() {
return Ok(ProcessResult {
text: text.to_string(),
corrections,
});
}
Ok(ProcessResult {
text: out.join(" "),
corrections,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_context() -> ContextSnapshot {
ContextSnapshot::default()
}
#[tokio::test]
async fn self_correction_with_no_wait() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("I want to go to Boston no wait to Denver", &empty_context())
.await
.unwrap();
assert!(
result.text.contains("Denver"),
"repair should be kept: {}",
result.text
);
assert!(
!result.text.contains("Boston"),
"reparandum should be removed: {}",
result.text
);
assert_eq!(result.corrections.len(), 1);
assert_eq!(
result.corrections[0].kind,
CorrectionKind::SelfCorrectionRemoved
);
}
#[tokio::test]
async fn self_correction_with_i_mean() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process(
"the meeting is tomorrow i mean the meeting is on Friday",
&empty_context(),
)
.await
.unwrap();
assert!(
result.text.contains("Friday"),
"repair should be kept: {}",
result.text
);
}
#[tokio::test]
async fn no_self_correction_in_clean_text() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process(
"I want to go to Denver for the conference",
&empty_context(),
)
.await
.unwrap();
assert_eq!(result.text, "I want to go to Denver for the conference");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn japanese_self_correction() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("明日、いや明後日に会議があります", &empty_context())
.await
.unwrap();
assert!(
result.text.contains("明後日"),
"repair should be kept: {}",
result.text
);
assert!(
!result.text.contains("明日"),
"reparandum should be removed: {}",
result.text
);
}
#[tokio::test]
async fn japanese_self_correction_with_janakute() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("東京駅、じゃなくて品川駅で待ち合わせ", &empty_context())
.await
.unwrap();
assert!(result.text.contains("品川駅"), "repair: {}", result.text);
assert!(
!result.text.contains("東京駅"),
"reparandum: {}",
result.text
);
}
#[tokio::test]
async fn spanish_self_correction_with_no() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("voy mañana no voy hoy", &empty_context())
.await
.unwrap();
assert!(result.text.contains("hoy"), "repair: {}", result.text);
assert!(
!result.text.contains("mañana"),
"reparandum: {}",
result.text
);
assert_eq!(result.corrections.len(), 1);
}
#[tokio::test]
async fn spanish_self_correction_with_perdon() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("voy a Madrid perdón a Barcelona", &empty_context())
.await
.unwrap();
assert!(result.text.contains("Barcelona"), "repair: {}", result.text);
assert!(
!result.text.contains("Madrid"),
"reparandum: {}",
result.text
);
}
#[tokio::test]
async fn spanish_self_correction_with_digo() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("el presidente digo el ex-presidente", &empty_context())
.await
.unwrap();
assert!(
result.text.contains("ex-presidente"),
"repair: {}",
result.text
);
}
#[tokio::test]
async fn spanish_self_correction_with_mejor_dicho() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("salgo a las cinco mejor dicho a las seis", &empty_context())
.await
.unwrap();
assert!(result.text.contains("seis"), "repair: {}", result.text);
assert!(
!result.text.contains("cinco"),
"reparandum: {}",
result.text
);
}
#[tokio::test]
async fn spanish_self_correction_with_o_sea() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("llamo a Juan o sea a Pedro", &empty_context())
.await
.unwrap();
assert!(result.text.contains("Pedro"), "repair: {}", result.text);
assert!(!result.text.contains("Juan"), "reparandum: {}", result.text);
}
#[tokio::test]
async fn spanish_negation_without_overlap_is_not_correction() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("el gato no come pescado", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "el gato no come pescado");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn spanish_no_self_correction_in_clean_text() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("la conferencia es mañana en Barcelona", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "la conferencia es mañana en Barcelona");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn spanish_self_correction_with_commas() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("voy a Madrid, perdón, a Barcelona", &empty_context())
.await
.unwrap();
assert!(result.text.contains("Barcelona"), "repair: {}", result.text);
assert!(
!result.text.contains("Madrid"),
"reparandum: {}",
result.text
);
}
#[tokio::test]
async fn spanish_no_does_not_match_inside_word() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("Mariano y Antonio fueron al cine", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "Mariano y Antonio fueron al cine");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn spanish_quiero_decir_outranks_digo() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("voy a Madrid quiero decir a Barcelona", &empty_context())
.await
.unwrap();
assert!(result.text.contains("Barcelona"), "repair: {}", result.text);
assert!(
!result.text.contains("Madrid"),
"reparandum: {}",
result.text
);
}
#[tokio::test]
async fn capitalize_first_word() {
let proc = BasicPunctuationRestorer;
let result = proc.process("hello world", &empty_context()).await.unwrap();
assert!(result.text.starts_with('H'));
}
#[tokio::test]
async fn add_terminal_period() {
let proc = BasicPunctuationRestorer;
let result = proc.process("hello world", &empty_context()).await.unwrap();
assert!(result.text.ends_with('.'));
}
#[tokio::test]
async fn punctuation_appends_period_for_korean() {
let proc = BasicPunctuationRestorer;
let result = proc
.process("내일 오후 세 시에 만나기로 했습니다", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "내일 오후 세 시에 만나기로 했습니다.");
}
#[tokio::test]
async fn punctuation_appends_fullwidth_period_for_chinese() {
let proc = BasicPunctuationRestorer;
let result = proc
.process("会议在下午三点开始", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "会议在下午三点开始。");
}
#[tokio::test]
async fn punctuation_appends_fullwidth_period_for_japanese() {
let proc = BasicPunctuationRestorer;
let result = proc
.process("今日は天気がいい", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "今日は天気がいい。");
}
#[tokio::test]
async fn punctuation_does_not_double_terminal_for_korean() {
let proc = BasicPunctuationRestorer;
let result = proc
.process("이것은 정말 좋은 책입니다.", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "이것은 정말 좋은 책입니다.");
}
#[tokio::test]
async fn punctuation_does_not_double_terminal_for_chinese() {
let proc = BasicPunctuationRestorer;
let result = proc
.process("这是一本很好的书。", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "这是一本很好的书。");
}
#[tokio::test]
async fn punctuation_skips_capitalisation_for_hangul() {
let proc = BasicPunctuationRestorer;
let result = proc
.process("안녕하세요 반갑습니다", &empty_context())
.await
.unwrap();
assert!(
!result
.corrections
.iter()
.any(|c| matches!(c.kind, CorrectionKind::Capitalized)),
"{:?}",
result.corrections
);
}
#[tokio::test]
async fn preserve_existing_punctuation() {
let proc = BasicPunctuationRestorer;
let result = proc
.process("Hello world!", &empty_context())
.await
.unwrap();
assert!(result.text.ends_with('!'));
assert!(!result.text.ends_with("!."));
}
#[tokio::test]
async fn capitalize_after_period() {
let proc = BasicPunctuationRestorer;
let result = proc
.process("hello. world", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "Hello. World.");
}
#[tokio::test]
async fn japanese_no_terminal_period_added() {
let proc = BasicPunctuationRestorer;
let result = proc
.process("お江戸を発って二十里上方", &empty_context())
.await
.unwrap();
assert!(
!result.text.ends_with('.'),
"Japanese should not get English period: {}",
result.text
);
}
#[tokio::test]
async fn empty_text() {
let proc = BasicPunctuationRestorer;
let result = proc.process("", &empty_context()).await.unwrap();
assert_eq!(result.text, "");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn ja_self_correction_with_tte_iu_ka() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("鈴木課長、っていうか佐藤課長です", &empty_context())
.await
.unwrap();
assert!(
!result.text.contains("鈴木課長"),
"reparandum should be removed: {}",
result.text,
);
assert!(
result.text.contains("佐藤課長"),
"repair should be kept: {}",
result.text,
);
assert_eq!(result.text, "佐藤課長です");
}
#[tokio::test]
async fn en_self_correction_no_wait_prefers_long_cue() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("I want to go to Boston no wait to Denver", &empty_context())
.await
.unwrap();
assert!(!result.text.contains("Boston"), "{}", result.text);
assert!(result.text.contains("Denver"), "{}", result.text);
}
#[tokio::test]
async fn chinese_self_correction_with_bu_dui() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("我们明天开会,不对,后天开会", &empty_context())
.await
.unwrap();
assert!(result.text.contains("后天"), "repair: {}", result.text);
assert!(!result.text.contains("明天"), "reparandum: {}", result.text);
}
#[tokio::test]
async fn chinese_self_correction_with_wo_shi_shuo() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("会议室在三楼,我是说,在四楼", &empty_context())
.await
.unwrap();
assert!(result.text.contains("四楼"), "{}", result.text);
assert!(!result.text.contains("三楼"), "{}", result.text);
}
#[tokio::test]
async fn chinese_self_correction_with_bu_shi() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("我去上海,不是,我去北京", &empty_context())
.await
.unwrap();
assert!(result.text.contains("北京"), "{}", result.text);
}
#[tokio::test]
async fn chinese_no_self_correction_in_clean_text() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("会议在下午三点开始", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "会议在下午三点开始");
assert_eq!(result.corrections.len(), 0);
}
#[tokio::test]
async fn korean_self_correction_single_eojeol_repair() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("8시 아니 9시에 만나자", &empty_context())
.await
.unwrap();
assert!(result.text.contains("9시"), "repair: {}", result.text);
assert!(!result.text.contains("8시"), "reparandum: {}", result.text);
}
#[tokio::test]
async fn korean_self_correction_with_geuge_anira() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("내가 갈게 그게 아니라 네가 갈게", &empty_context())
.await
.unwrap();
assert!(result.text.contains("네가"), "{}", result.text);
assert!(!result.text.contains("내가"), "{}", result.text);
}
#[tokio::test]
async fn korean_self_correction_with_jamkkanman() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("회의실은 삼층 잠깐만 사층입니다", &empty_context())
.await
.unwrap();
assert!(result.text.contains("사층"), "{}", result.text);
}
#[tokio::test]
async fn korean_self_correction_with_shared_prefix_overlap() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("오늘 갈게 아니 오늘 안 갈게", &empty_context())
.await
.unwrap();
assert!(result.text.contains("안 갈게"), "{}", result.text);
}
#[tokio::test]
async fn korean_no_self_correction_in_clean_text() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("내일 오후 세 시에 만나기로 했습니다", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "내일 오후 세 시에 만나기로 했습니다");
assert_eq!(result.corrections.len(), 0);
}
#[tokio::test]
async fn korean_ani_inside_word_does_not_trigger() {
let detector = SelfCorrectionDetector::new();
let result = detector
.process("이것은 아니에요", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "이것은 아니에요");
}
#[tokio::test]
async fn itn_english_sentence_normalizes_numerals() {
let itn = InverseTextNormalizer::new("en");
let result = itn
.process("i have twenty five dollars", &empty_context())
.await
.unwrap();
assert!(
result.text.contains("$25"),
"expected written-form currency: {}",
result.text
);
assert_eq!(result.corrections.len(), 1);
assert_eq!(
result.corrections[0].kind,
CorrectionKind::NumeralNormalized
);
}
#[tokio::test]
async fn itn_chinese_sentence_normalizes_numerals() {
let itn = InverseTextNormalizer::new("zh");
let result = itn
.process("我有二十五块钱", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "我有25块钱");
}
#[tokio::test]
async fn itn_japanese_sentence_normalizes_numerals() {
let itn = InverseTextNormalizer::new("ja");
let result = itn
.process("そこに鳥一羽がいます", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "そこに鳥1羽がいます");
}
#[tokio::test]
async fn itn_clean_text_emits_no_correction() {
let itn = InverseTextNormalizer::new("en");
let result = itn
.process("the meeting is on friday", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "the meeting is on friday");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn itn_spanish_passes_through_until_upstream_support() {
let itn = InverseTextNormalizer::new("es");
let result = itn
.process("tengo veinticinco años", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "tengo veinticinco años");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn itn_korean_passes_through_until_upstream_support() {
let itn = InverseTextNormalizer::new("ko");
let result = itn
.process("이천이십육년에 만났다", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "이천이십육년에 만났다");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn itn_unknown_language_passes_through() {
let itn = InverseTextNormalizer::new("xx");
let result = itn
.process("arbitrary text", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "arbitrary text");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn spoken_form_expands_english_reductions() {
let sfn = SpokenFormNormalizer::new("en");
let result = sfn
.process("i'm gonna grab a coffee", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "i'm going to grab a coffee");
assert_eq!(result.corrections.len(), 1);
assert_eq!(
result.corrections[0].kind,
CorrectionKind::SpokenFormNormalized
);
}
#[tokio::test]
async fn spoken_form_handles_multiple_and_punctuation() {
let sfn = SpokenFormNormalizer::new("en");
let result = sfn
.process("lemme know, i kinda forgot.", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "let me know, i kind of forgot.");
assert_eq!(result.corrections.len(), 2);
}
#[tokio::test]
async fn spoken_form_preserves_leading_capitalization() {
let sfn = SpokenFormNormalizer::new("en");
let result = sfn
.process("Gonna head out now", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "Going to head out now");
}
#[tokio::test]
async fn spoken_form_keeps_apostrophe_words() {
let sfn = SpokenFormNormalizer::new("en");
let result = sfn
.process("c'mon we leave 'cause it's late", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "come on we leave because it's late");
}
#[tokio::test]
async fn spoken_form_clean_text_is_untouched() {
let sfn = SpokenFormNormalizer::new("en");
let result = sfn
.process("the meeting is on friday", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "the meeting is on friday");
assert!(result.corrections.is_empty());
}
#[tokio::test]
async fn spoken_form_unsupported_language_passes_through() {
let sfn = SpokenFormNormalizer::new("ja");
let result = sfn
.process("これはテストです", &empty_context())
.await
.unwrap();
assert_eq!(result.text, "これはテストです");
assert!(result.corrections.is_empty());
}
}