use crate::types::{Language, Span};
use async_trait::async_trait;
#[async_trait]
pub trait TextFilter: Send + Sync {
async fn filter(&self, text: &str) -> Result<FilterResult, FilterError>;
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct FilterResult {
pub text: String,
pub removed: Vec<String>,
}
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum FilterError {
#[error("filter resource unavailable: {0}")]
Unavailable(String),
#[error("filter failed: {0}")]
Failed(String),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum FillerFilter {
Word(SimpleFillerFilter),
Japanese(JapaneseFillerFilter),
Chinese(ChineseFillerFilter),
Spanish(SpanishFillerFilter),
}
impl FillerFilter {
pub fn for_language(language: Language) -> Self {
match language {
Language::English => Self::Word(SimpleFillerFilter::english()),
Language::Korean => Self::Word(SimpleFillerFilter::korean()),
Language::Japanese => Self::Japanese(JapaneseFillerFilter::new()),
Language::Chinese => Self::Chinese(ChineseFillerFilter::new()),
Language::Spanish => Self::Spanish(SpanishFillerFilter::new()),
}
}
pub fn detect_spans(&self, text: &str) -> Vec<Span> {
match self {
Self::Word(f) => f.detect_spans(text),
Self::Japanese(f) => f.detect_spans(text),
Self::Chinese(f) => f.detect_spans(text),
Self::Spanish(f) => f.detect_spans(text),
}
}
}
#[async_trait]
impl TextFilter for FillerFilter {
async fn filter(&self, text: &str) -> Result<FilterResult, FilterError> {
match self {
Self::Word(f) => f.filter(text).await,
Self::Japanese(f) => f.filter(text).await,
Self::Chinese(f) => f.filter(text).await,
Self::Spanish(f) => f.filter(text).await,
}
}
}
#[derive(Debug, Clone)]
pub struct SimpleFillerFilter {
pure_fillers: Vec<String>,
contextual_fillers: Vec<String>,
multi_fillers: Vec<Vec<String>>,
}
impl SimpleFillerFilter {
pub fn english() -> Self {
Self {
pure_fillers: vec!["um", "uh", "uhm", "umm", "hmm", "er", "ah", "eh"]
.into_iter()
.map(String::from)
.collect(),
contextual_fillers: vec!["so", "well", "basically", "actually", "literally", "right"]
.into_iter()
.map(String::from)
.collect(),
multi_fillers: vec![
vec!["you".into(), "know".into()],
vec!["i".into(), "mean".into()],
vec!["you".into(), "see".into()],
vec!["sort".into(), "of".into()],
vec!["kind".into(), "of".into()],
vec!["or".into(), "something".into()],
vec!["or".into(), "whatever".into()],
],
}
}
pub fn with_pure_fillers(mut self, fillers: Vec<String>) -> Self {
self.pure_fillers = fillers;
self
}
pub fn with_contextual_fillers(mut self, fillers: Vec<String>) -> Self {
self.contextual_fillers = fillers;
self
}
pub fn korean() -> Self {
Self {
pure_fillers: vec![
"음", "어", "아", "으", "엄", "응",
]
.into_iter()
.map(String::from)
.collect(),
contextual_fillers: vec![
"그",
"저",
"막",
"약간",
"뭐",
"저기",
"글쎄",
"그러니까",
"그니까",
"그래서",
"어쨌든",
"그게",
"뭐랄까",
]
.into_iter()
.map(String::from)
.collect(),
multi_fillers: vec![
vec!["음".into(), "그러니까".into()],
vec!["그게".into(), "그러니까".into()],
vec!["뭐랄까".into(), "그".into()],
],
}
}
fn is_sentence_initial(words: &[&str], idx: usize, removed_indices: &[bool]) -> bool {
if idx == 0 {
return true;
}
for j in (0..idx).rev() {
if removed_indices[j] {
continue;
}
let prev = words[j];
if prev.ends_with('.')
|| prev.ends_with('!')
|| prev.ends_with('?')
|| prev.ends_with(',')
{
return true;
}
return false;
}
true
}
pub fn detect_spans(&self, text: &str) -> Vec<Span> {
let chars: Vec<char> = text.chars().collect();
let n_chars = chars.len();
struct Tok {
cp_start: usize,
cp_end: usize,
clean_lower: String,
ends_with_terminal: bool,
}
let mut tokens: Vec<Tok> = Vec::new();
let mut i = 0;
while i < n_chars {
while i < n_chars && chars[i].is_whitespace() {
i += 1;
}
if i >= n_chars {
break;
}
let start = i;
while i < n_chars && !chars[i].is_whitespace() {
i += 1;
}
let surface: String = chars[start..i].iter().collect();
let lower = surface.to_lowercase();
let clean: String = lower.chars().take_while(|c| c.is_alphanumeric()).collect();
let ends_with_terminal = surface.ends_with('.')
|| surface.ends_with('!')
|| surface.ends_with('?')
|| surface.ends_with(',');
tokens.push(Tok {
cp_start: start,
cp_end: i,
clean_lower: clean,
ends_with_terminal,
});
}
let n = tokens.len();
let mut removed = vec![false; n];
let mut detections: Vec<(usize, usize)> = Vec::new();
let mut k = 0;
'outer: while k + 1 < n {
for mf in &self.multi_fillers {
if mf.len() == 2
&& !removed[k]
&& !removed[k + 1]
&& tokens[k].clean_lower == mf[0]
&& tokens[k + 1].clean_lower == mf[1]
{
removed[k] = true;
removed[k + 1] = true;
detections.push((k, k + 1));
k += 2;
continue 'outer;
}
}
k += 1;
}
for j in 0..n {
if removed[j] {
continue;
}
if self.pure_fillers.contains(&tokens[j].clean_lower) {
removed[j] = true;
detections.push((j, j));
}
}
let is_initial = |idx: usize, rem: &[bool]| -> bool {
if idx == 0 {
return true;
}
for jj in (0..idx).rev() {
if rem[jj] {
continue;
}
return tokens[jj].ends_with_terminal;
}
true
};
for j in 0..n {
if removed[j] {
continue;
}
if self.contextual_fillers.contains(&tokens[j].clean_lower) && is_initial(j, &removed) {
removed[j] = true;
detections.push((j, j));
}
}
let mut spans: Vec<Span> = detections
.iter()
.map(|(s, e)| crate::types::Span {
start: tokens[*s].cp_start,
end: tokens[*e].cp_end,
})
.collect();
spans.sort_by_key(|s| s.start);
spans
}
}
#[async_trait]
impl TextFilter for SimpleFillerFilter {
async fn filter(&self, text: &str) -> Result<FilterResult, FilterError> {
let words: Vec<&str> = text.split_whitespace().collect();
let n = words.len();
let mut removed_indices = vec![false; n];
let mut removed_labels: Vec<String> = Vec::new();
let mut i = 0;
'outer: while i + 1 < n {
for mf in &self.multi_fillers {
if mf.len() == 2 && !removed_indices[i] && !removed_indices[i + 1] {
let w0 = words[i].to_lowercase();
let w1 = words[i + 1].to_lowercase();
let clean0: String = w0.chars().take_while(|c| c.is_alphanumeric()).collect();
let clean1: String = w1.chars().take_while(|c| c.is_alphanumeric()).collect();
if clean0 == mf[0] && clean1 == mf[1] {
removed_indices[i] = true;
removed_indices[i + 1] = true;
removed_labels.push(format!("{} {}", words[i], words[i + 1]));
i += 2;
continue 'outer;
}
}
}
i += 1;
}
for i in 0..n {
if removed_indices[i] {
continue;
}
let lower = words[i].to_lowercase();
let clean: String = lower.chars().take_while(|c| c.is_alphanumeric()).collect();
if self.pure_fillers.contains(&clean) {
removed_indices[i] = true;
removed_labels.push(words[i].to_string());
}
}
for i in 0..n {
if removed_indices[i] {
continue;
}
let lower = words[i].to_lowercase();
let clean: String = lower.chars().take_while(|c| c.is_alphanumeric()).collect();
if self.contextual_fillers.contains(&clean)
&& Self::is_sentence_initial(&words, i, &removed_indices)
{
removed_indices[i] = true;
removed_labels.push(words[i].to_string());
}
}
let kept: Vec<&str> = words
.iter()
.enumerate()
.filter(|(i, _)| !removed_indices[*i])
.map(|(_, w)| *w)
.collect();
Ok(FilterResult {
text: kept.join(" "),
removed: removed_labels,
})
}
}
#[derive(Debug, Clone)]
pub struct JapaneseFillerFilter {
pure_fillers: Vec<String>,
contextual_fillers: Vec<String>,
}
impl JapaneseFillerFilter {
pub fn new() -> Self {
Self {
pure_fillers: vec![
"えーと",
"えっと",
"えー",
"あー",
"うーん",
"うん",
"ああ",
"ええ",
"映像",
"映映",
]
.into_iter()
.map(String::from)
.collect(),
contextual_fillers: vec!["あの", "まあ", "その", "なんか", "ほら", "やっぱり"]
.into_iter()
.map(String::from)
.collect(),
}
}
}
impl Default for JapaneseFillerFilter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl TextFilter for JapaneseFillerFilter {
async fn filter(&self, text: &str) -> Result<FilterResult, FilterError> {
let segments: Vec<&str> = text.split('、').collect();
let n = segments.len();
let mut removed_indices = vec![false; n];
let mut removed_labels: Vec<String> = Vec::new();
for i in 0..n {
let trimmed = segments[i].trim();
if trimmed.is_empty() {
continue;
}
if self.pure_fillers.iter().any(|f| trimmed == f) {
removed_indices[i] = true;
removed_labels.push(trimmed.to_string());
}
}
for i in 0..n {
if removed_indices[i] {
continue;
}
let trimmed = segments[i].trim();
if trimmed.is_empty() {
continue;
}
let is_initial = (0..i).all(|j| removed_indices[j] || segments[j].trim().is_empty());
let after_period =
i > 0 && !removed_indices[i - 1] && segments[i - 1].trim().ends_with('。');
if self.contextual_fillers.iter().any(|f| trimmed == f) && (is_initial || after_period)
{
removed_indices[i] = true;
removed_labels.push(trimmed.to_string());
}
}
for i in 0..n {
if removed_indices[i] {
continue;
}
let trimmed = segments[i].trim();
if trimmed.is_empty() {
continue;
}
if self.contextual_fillers.iter().any(|f| trimmed == f) {
removed_indices[i] = true;
removed_labels.push(trimmed.to_string());
}
}
let kept: Vec<&str> = segments
.iter()
.enumerate()
.filter(|(i, _)| !removed_indices[*i])
.map(|(_, s)| *s)
.collect();
let mut result = kept.join("、");
while result.starts_with('、') {
result = result[3..].to_string(); }
while result.contains("、、") {
result = result.replace("、、", "、");
}
Ok(FilterResult {
text: result.trim().to_string(),
removed: removed_labels,
})
}
}
impl JapaneseFillerFilter {
pub fn detect_spans(&self, text: &str) -> Vec<Span> {
let chars: Vec<char> = text.chars().collect();
let n_chars = chars.len();
struct Seg {
cp_inner_start: usize,
cp_inner_end: usize,
trimmed: String,
ends_with_terminal: bool,
}
let mut segs: Vec<Seg> = Vec::new();
let mut seg_start = 0usize;
let mut j = 0;
loop {
let at_terminator = j == n_chars || chars[j] == '、';
if at_terminator {
let inner = &chars[seg_start..j];
let mut tl = 0;
while tl < inner.len() && inner[tl].is_whitespace() {
tl += 1;
}
let mut tr = inner.len();
while tr > tl && inner[tr - 1].is_whitespace() {
tr -= 1;
}
let trimmed: String = inner[tl..tr].iter().collect();
let ends_with_terminal = trimmed.ends_with('。');
segs.push(Seg {
cp_inner_start: seg_start + tl,
cp_inner_end: seg_start + tr,
trimmed,
ends_with_terminal,
});
if j == n_chars {
break;
}
seg_start = j + 1; }
j += 1;
}
let n = segs.len();
let mut removed = vec![false; n];
let mut detections: Vec<usize> = Vec::new();
for i in 0..n {
if segs[i].trimmed.is_empty() {
continue;
}
if self.pure_fillers.iter().any(|f| segs[i].trimmed == *f) {
removed[i] = true;
detections.push(i);
}
}
for i in 0..n {
if removed[i] || segs[i].trimmed.is_empty() {
continue;
}
let is_initial = (0..i).all(|k| removed[k] || segs[k].trimmed.is_empty());
let after_period = i > 0 && !removed[i - 1] && segs[i - 1].ends_with_terminal;
if self
.contextual_fillers
.iter()
.any(|f| segs[i].trimmed == *f)
&& (is_initial || after_period)
{
removed[i] = true;
detections.push(i);
}
}
for i in 0..n {
if removed[i] || segs[i].trimmed.is_empty() {
continue;
}
if self
.contextual_fillers
.iter()
.any(|f| segs[i].trimmed == *f)
{
removed[i] = true;
detections.push(i);
}
}
let mut spans: Vec<Span> = detections
.into_iter()
.map(|i| crate::types::Span {
start: segs[i].cp_inner_start,
end: segs[i].cp_inner_end,
})
.collect();
spans.sort_by_key(|s| s.start);
spans
}
}
#[derive(Debug, Clone)]
pub struct ChineseFillerFilter {
pure_fillers: Vec<String>,
contextual_fillers: Vec<String>,
}
impl ChineseFillerFilter {
pub fn new() -> Self {
Self {
pure_fillers: vec!["嗯", "呃", "哦", "唉", "呀"]
.into_iter()
.map(String::from)
.collect(),
contextual_fillers: vec!["那个", "这个", "就是", "然后", "怎么说"]
.into_iter()
.map(String::from)
.collect(),
}
}
}
impl Default for ChineseFillerFilter {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl TextFilter for ChineseFillerFilter {
async fn filter(&self, text: &str) -> Result<FilterResult, FilterError> {
let normalized = text.replace(',', ",");
let segments: Vec<&str> = normalized.split(',').collect();
let n = segments.len();
let mut removed_indices = vec![false; n];
let mut removed_labels: Vec<String> = Vec::new();
for i in 0..n {
let trimmed = segments[i].trim();
if trimmed.is_empty() {
continue;
}
if self.pure_fillers.iter().any(|f| trimmed == f) {
removed_indices[i] = true;
removed_labels.push(trimmed.to_string());
}
}
for i in 0..n {
if removed_indices[i] {
continue;
}
let trimmed = segments[i].trim();
if trimmed.is_empty() {
continue;
}
let is_initial = (0..i).all(|j| removed_indices[j] || segments[j].trim().is_empty());
let after_period =
i > 0 && !removed_indices[i - 1] && segments[i - 1].trim().ends_with('。');
if self.contextual_fillers.iter().any(|f| trimmed == f) && (is_initial || after_period)
{
removed_indices[i] = true;
removed_labels.push(trimmed.to_string());
}
}
for i in 0..n {
if removed_indices[i] {
continue;
}
let trimmed = segments[i].trim();
if trimmed.is_empty() {
continue;
}
if self.contextual_fillers.iter().any(|f| trimmed == f) {
removed_indices[i] = true;
removed_labels.push(trimmed.to_string());
}
}
let kept: Vec<&str> = segments
.iter()
.enumerate()
.filter(|(i, _)| !removed_indices[*i])
.map(|(_, s)| *s)
.collect();
let mut result = kept.join(",");
while result.starts_with(',') {
result = result[3..].to_string();
}
while result.contains(",,") {
result = result.replace(",,", ",");
}
Ok(FilterResult {
text: result.trim().to_string(),
removed: removed_labels,
})
}
}
impl ChineseFillerFilter {
pub fn detect_spans(&self, text: &str) -> Vec<Span> {
let chars: Vec<char> = text.chars().collect();
let n_chars = chars.len();
struct Seg {
cp_inner_start: usize,
cp_inner_end: usize,
trimmed: String,
ends_with_terminal: bool,
}
let mut segs: Vec<Seg> = Vec::new();
let mut seg_start = 0usize;
let mut j = 0;
loop {
let at_terminator = j == n_chars || chars[j] == ',';
if at_terminator {
let inner = &chars[seg_start..j];
let mut tl = 0;
while tl < inner.len() && inner[tl].is_whitespace() {
tl += 1;
}
let mut tr = inner.len();
while tr > tl && inner[tr - 1].is_whitespace() {
tr -= 1;
}
let trimmed: String = inner[tl..tr].iter().collect();
let ends_with_terminal = trimmed.ends_with('。');
segs.push(Seg {
cp_inner_start: seg_start + tl,
cp_inner_end: seg_start + tr,
trimmed,
ends_with_terminal,
});
if j == n_chars {
break;
}
seg_start = j + 1;
}
j += 1;
}
let n = segs.len();
let mut removed = vec![false; n];
let mut detections: Vec<usize> = Vec::new();
for i in 0..n {
if segs[i].trimmed.is_empty() {
continue;
}
if self.pure_fillers.iter().any(|f| segs[i].trimmed == *f) {
removed[i] = true;
detections.push(i);
}
}
for i in 0..n {
if removed[i] || segs[i].trimmed.is_empty() {
continue;
}
let is_initial = (0..i).all(|k| removed[k] || segs[k].trimmed.is_empty());
let after_period = i > 0 && !removed[i - 1] && segs[i - 1].ends_with_terminal;
if self
.contextual_fillers
.iter()
.any(|f| segs[i].trimmed == *f)
&& (is_initial || after_period)
{
removed[i] = true;
detections.push(i);
}
}
for i in 0..n {
if removed[i] || segs[i].trimmed.is_empty() {
continue;
}
if self
.contextual_fillers
.iter()
.any(|f| segs[i].trimmed == *f)
{
removed[i] = true;
detections.push(i);
}
}
let mut spans: Vec<Span> = detections
.into_iter()
.map(|i| crate::types::Span {
start: segs[i].cp_inner_start,
end: segs[i].cp_inner_end,
})
.collect();
spans.sort_by_key(|s| s.start);
spans
}
}
#[derive(Debug, Clone)]
pub struct SpanishFillerFilter {
pure_fillers: Vec<String>,
multi_fillers: Vec<Vec<String>>,
partial_stoplist: Vec<String>,
}
impl SpanishFillerFilter {
pub fn new() -> Self {
Self {
pure_fillers: vec![
"e", "eh", "ehh", "ehhh", "eee", "eeh", "eeeh", "ehm",
"mm", "mmm", "hmm", "ah", "aah", "ahh", "oh",
]
.into_iter()
.map(String::from)
.collect(),
multi_fillers: vec![vec!["o".into(), "sea".into()]],
partial_stoplist: vec![
"y", "a", "o", "u", "e", "de", "en", "el", "la", "lo", "los", "las", "le", "les",
"un", "una", "uno", "unos", "unas", "se", "te", "me", "nos", "os", "no", "ni",
"es", "ya", "si", "sí", "por", "con", "para", "del", "al", "que", "qué", "su",
"sus", "mi", "mis", "tu", "tus",
]
.into_iter()
.map(String::from)
.collect(),
}
}
}
impl Default for SpanishFillerFilter {
fn default() -> Self {
Self::new()
}
}
struct EsToken {
surface: String,
lower: String,
char_count: usize,
char_start: usize,
char_end: usize,
}
fn tokenize_es(text: &str) -> Vec<EsToken> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut out = Vec::new();
let mut i = 0;
while i < n {
while i < n && chars[i].is_whitespace() {
i += 1;
}
if i >= n {
break;
}
let start = i;
while i < n && !chars[i].is_whitespace() {
i += 1;
}
let surface: String = chars[start..i].iter().collect();
let lower = surface
.to_lowercase()
.trim_matches(|c: char| !c.is_alphanumeric())
.to_string();
let char_count = lower.chars().count();
out.push(EsToken {
surface,
lower,
char_count,
char_start: start,
char_end: i,
});
}
out
}
struct EsDetection {
start_token: usize,
end_token: usize,
}
impl SpanishFillerFilter {
fn run_passes(&self, tokens: &[EsToken]) -> (Vec<bool>, Vec<EsDetection>) {
let n = tokens.len();
let mut removed = vec![false; n];
let mut detections: Vec<EsDetection> = Vec::new();
for mf in &self.multi_fillers {
let plen = mf.len();
if plen == 0 || plen > n {
continue;
}
let mut i = 0;
while i + plen <= n {
let matches = (0..plen).all(|k| !removed[i + k] && tokens[i + k].lower == mf[k]);
if matches {
detections.push(EsDetection {
start_token: i,
end_token: i + plen - 1,
});
for k in 0..plen {
removed[i + k] = true;
}
i += plen;
} else {
i += 1;
}
}
}
if n >= 4 {
let mut i = 0;
while i + 4 <= n {
let any_covered = (0..4).any(|k| removed[i + k]);
if !any_covered
&& tokens[i].lower == tokens[i + 2].lower
&& tokens[i + 1].lower == tokens[i + 3].lower
&& tokens[i].char_count + tokens[i + 1].char_count >= 3
{
detections.push(EsDetection {
start_token: i,
end_token: i + 1,
});
removed[i] = true;
removed[i + 1] = true;
i += 2;
} else {
i += 1;
}
}
}
for j in 1..n {
if removed[j] || removed[j - 1] {
continue;
}
if tokens[j].lower == tokens[j - 1].lower && tokens[j].char_count >= 2 {
removed[j - 1] = true;
detections.push(EsDetection {
start_token: j - 1,
end_token: j - 1,
});
}
}
if n >= 2 {
for j in 0..n - 1 {
if removed[j] || removed[j + 1] {
continue;
}
let a = &tokens[j].lower;
let b = &tokens[j + 1].lower;
if tokens[j].char_count >= 2
&& tokens[j + 1].char_count >= tokens[j].char_count + 2
&& b.starts_with(a.as_str())
&& !self.partial_stoplist.contains(a)
{
removed[j] = true;
detections.push(EsDetection {
start_token: j,
end_token: j,
});
}
}
}
for j in 0..n {
if removed[j] {
continue;
}
if self.pure_fillers.contains(&tokens[j].lower) {
removed[j] = true;
detections.push(EsDetection {
start_token: j,
end_token: j,
});
}
}
(removed, detections)
}
pub fn detect_spans(&self, text: &str) -> Vec<Span> {
let tokens = tokenize_es(text);
let (_removed, detections) = self.run_passes(&tokens);
let mut spans: Vec<Span> = detections
.iter()
.map(|d| crate::types::Span {
start: tokens[d.start_token].char_start,
end: tokens[d.end_token].char_end,
})
.collect();
spans.sort_by_key(|s| s.start);
spans
}
fn detection_label(&self, tokens: &[EsToken], d: &EsDetection) -> String {
if d.start_token == d.end_token {
tokens[d.start_token].surface.clone()
} else {
(d.start_token..=d.end_token)
.map(|i| tokens[i].surface.as_str())
.collect::<Vec<_>>()
.join(" ")
}
}
}
#[async_trait]
impl TextFilter for SpanishFillerFilter {
async fn filter(&self, text: &str) -> Result<FilterResult, FilterError> {
let tokens = tokenize_es(text);
let (removed, mut detections) = self.run_passes(&tokens);
detections.sort_by_key(|d| d.start_token);
let removed_labels: Vec<String> = detections
.iter()
.map(|d| self.detection_label(&tokens, d))
.collect();
let kept: Vec<&str> = tokens
.iter()
.enumerate()
.filter(|(i, _)| !removed[*i])
.map(|(_, t)| t.surface.as_str())
.collect();
Ok(FilterResult {
text: kept.join(" "),
removed: removed_labels,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn simple_filler_removes_or_something_bigram() {
let filter = SimpleFillerFilter::english();
let result = filter.filter("we should deploy or something").await.unwrap();
assert_eq!(result.text, "we should deploy");
assert!(result.removed.iter().any(|r| r.to_lowercase().contains("or")));
}
#[tokio::test]
async fn simple_filler_removes_or_whatever_bigram() {
let filter = SimpleFillerFilter::english();
let result = filter.filter("restart the service or whatever").await.unwrap();
assert_eq!(result.text, "restart the service");
}
#[tokio::test]
async fn simple_filler_keeps_or_when_not_part_of_a_filler_bigram() {
let filter = SimpleFillerFilter::english();
let result = filter.filter("deploy or roll back").await.unwrap();
assert_eq!(result.text, "deploy or roll back");
assert!(result.removed.is_empty());
}
#[tokio::test]
async fn simple_filler_filter_removes_pure_fillers() {
let filter = SimpleFillerFilter::english();
let result = filter
.filter("um I think uh we should deploy")
.await
.unwrap();
assert_eq!(result.text, "I think we should deploy");
assert!(result.removed.contains(&"um".to_string()));
assert!(result.removed.contains(&"uh".to_string()));
}
#[tokio::test]
async fn simple_filler_filter_preserves_clean_text() {
let filter = SimpleFillerFilter::english();
let result = filter.filter("deploy the server now").await.unwrap();
assert_eq!(result.text, "deploy the server now");
assert!(result.removed.is_empty());
}
#[tokio::test]
async fn contextual_filler_removed_at_sentence_start() {
let filter = SimpleFillerFilter::english();
let result = filter.filter("So I think we should deploy").await.unwrap();
assert_eq!(result.text, "I think we should deploy");
assert!(result.removed.contains(&"So".to_string()));
}
#[tokio::test]
async fn contextual_word_preserved_mid_sentence() {
let filter = SimpleFillerFilter::english();
let result = filter.filter("I think so we should deploy").await.unwrap();
assert!(result.text.contains("so"));
}
#[tokio::test]
async fn well_preserved_as_content_word() {
let filter = SimpleFillerFilter::english();
let result = filter.filter("it went well today").await.unwrap();
assert_eq!(result.text, "it went well today");
}
#[tokio::test]
async fn well_removed_at_sentence_start() {
let filter = SimpleFillerFilter::english();
let result = filter
.filter("Well I think we should deploy")
.await
.unwrap();
assert_eq!(result.text, "I think we should deploy");
}
#[tokio::test]
async fn multi_word_filler_removed() {
let filter = SimpleFillerFilter::english();
let result = filter
.filter("I think you know we should deploy")
.await
.unwrap();
assert_eq!(result.text, "I think we should deploy");
assert!(result.removed.contains(&"you know".to_string()));
}
#[tokio::test]
async fn multi_word_filler_at_end_of_input() {
let filter = SimpleFillerFilter::english();
let result = filter.filter("deploy now you know").await.unwrap();
assert!(!result.text.contains("you know"), "{}", result.text);
assert!(result.removed.contains(&"you know".to_string()));
}
#[tokio::test]
async fn combined_pure_contextual_and_multi() {
let filter = SimpleFillerFilter::english();
let result = filter
.filter("Well um I think you know we should uh deploy")
.await
.unwrap();
assert_eq!(result.text, "I think we should deploy");
}
#[tokio::test]
async fn jfk_speech_contextual_so_preserved() {
let filter = SimpleFillerFilter::english();
let result = filter
.filter("And so my fellow Americans ask not what your country can do for you")
.await
.unwrap();
assert!(
result.text.contains("so"),
"mid-sentence 'so' should be kept: {}",
result.text
);
}
#[tokio::test]
async fn japanese_pure_filler_removed() {
let filter = JapaneseFillerFilter::new();
let result = filter.filter("えーと、拙者親方と申すは").await.unwrap();
assert_eq!(result.text, "拙者親方と申すは");
assert!(result.removed.contains(&"えーと".to_string()));
}
#[tokio::test]
async fn japanese_contextual_filler_at_start() {
let filter = JapaneseFillerFilter::new();
let result = filter.filter("あの、お立ち合いの中に").await.unwrap();
assert_eq!(result.text, "お立ち合いの中に");
assert!(result.removed.contains(&"あの".to_string()));
}
#[tokio::test]
async fn japanese_contextual_filler_standalone_removed() {
let filter = JapaneseFillerFilter::new();
let result = filter
.filter("拙者親方と申すは、あの、お立ち合いの中に")
.await
.unwrap();
assert!(
!result.text.contains("あの"),
"standalone あの should be removed: {}",
result.text
);
assert!(result.removed.contains(&"あの".to_string()));
}
#[tokio::test]
async fn japanese_contextual_word_in_phrase_preserved() {
let filter = JapaneseFillerFilter::new();
let result = filter
.filter("拙者親方と申すは、あの人が来た")
.await
.unwrap();
assert!(
result.text.contains("あの人が来た"),
"あの in phrase should be kept: {}",
result.text
);
}
#[tokio::test]
async fn japanese_multiple_fillers() {
let filter = JapaneseFillerFilter::new();
let result = filter
.filter("えーと、あの、お江戸を発って、えー、二十里上方")
.await
.unwrap();
assert!(result.removed.contains(&"えーと".to_string()));
assert!(result.removed.contains(&"えー".to_string()));
assert!(result.text.contains("お江戸を発って"));
assert!(result.text.contains("二十里上方"));
}
#[tokio::test]
async fn japanese_asr_artifact_removed() {
let filter = JapaneseFillerFilter::new();
let result = filter.filter("映像、拙者親方と申すは").await.unwrap();
assert_eq!(result.text, "拙者親方と申すは");
assert!(result.removed.contains(&"映像".to_string()));
}
#[tokio::test]
async fn japanese_clean_text_preserved() {
let filter = JapaneseFillerFilter::new();
let result = filter
.filter("拙者親方と申すは、お立ち合いの中に、御存じのお方もございましょうが")
.await
.unwrap();
assert_eq!(
result.text,
"拙者親方と申すは、お立ち合いの中に、御存じのお方もございましょうが"
);
assert!(result.removed.is_empty());
}
#[tokio::test]
async fn japanese_uiro_with_fillers_full() {
let filter = JapaneseFillerFilter::new();
let result = filter
.filter("映像、せっしゃおやかたとモースは、あの、おたち合いの中に、まあ、ご存知のおかたもございましょうが、その、お江戸をたってにじゅうりかみがた")
.await
.unwrap();
assert!(
!result.text.contains("映像"),
"ASR artifact should be removed"
);
assert!(result.text.contains("せっしゃおやかたとモースは"));
assert!(result.text.contains("ご存知のおかたもございましょうが"));
}
#[tokio::test]
async fn chinese_pure_filler_removed() {
let filter = ChineseFillerFilter::new();
let result = filter.filter("嗯,我们需要更新数据库").await.unwrap();
assert_eq!(result.text, "我们需要更新数据库");
assert!(result.removed.contains(&"嗯".to_string()));
}
#[tokio::test]
async fn chinese_two_pure_fillers() {
let filter = ChineseFillerFilter::new();
let result = filter.filter("呃,明天发布新版本,哦").await.unwrap();
assert!(!result.text.contains("呃"), "{}", result.text);
assert!(!result.text.contains("哦"), "{}", result.text);
assert!(result.text.contains("明天发布新版本"));
}
#[tokio::test]
async fn chinese_contextual_filler_at_sentence_start() {
let filter = ChineseFillerFilter::new();
let result = filter.filter("那个,测试全部通过了").await.unwrap();
assert_eq!(result.text, "测试全部通过了");
assert!(result.removed.contains(&"那个".to_string()));
}
#[tokio::test]
async fn chinese_contextual_word_in_phrase_preserved() {
let filter = ChineseFillerFilter::new();
let result = filter.filter("那个人是谁").await.unwrap();
assert_eq!(result.text, "那个人是谁");
assert!(result.removed.is_empty());
}
#[tokio::test]
async fn chinese_standalone_contextual_filler_mid_sentence() {
let filter = ChineseFillerFilter::new();
let result = filter
.filter("我觉得,那个,这个事情应该这样处理")
.await
.unwrap();
assert!(!result.text.contains(",那个,"), "{}", result.text);
assert!(result.text.contains("我觉得"));
assert!(result.text.contains("这个事情应该这样处理"));
}
#[tokio::test]
async fn chinese_clean_text_preserved() {
let filter = ChineseFillerFilter::new();
let result = filter
.filter("配置文件已经修改,缓存命中率不错")
.await
.unwrap();
assert_eq!(result.text, "配置文件已经修改,缓存命中率不错");
assert!(result.removed.is_empty());
}
#[tokio::test]
async fn chinese_halfwidth_comma_normalised() {
let filter = ChineseFillerFilter::new();
let result = filter.filter("嗯,我们需要更新数据库").await.unwrap();
assert!(!result.text.contains("嗯"));
assert!(result.text.contains("我们需要更新数据库"));
}
#[tokio::test]
async fn chinese_contextual_after_sentence_end() {
let filter = ChineseFillerFilter::new();
let result = filter
.filter("第一个任务完成。,然后,开始第二个")
.await
.unwrap();
assert!(!result.text.contains("然后"), "{}", result.text);
assert!(result.text.contains("开始第二个"));
}
#[tokio::test]
async fn spanish_pure_filler_removed_at_start() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("e fuera del aire").await.unwrap();
assert_eq!(result.text, "fuera del aire");
assert!(result.removed.contains(&"e".to_string()));
}
#[tokio::test]
async fn spanish_pure_filler_mid_utterance() {
let filter = SpanishFillerFilter::new();
let result = filter
.filter("hace rato comentaba e fuera del aire")
.await
.unwrap();
assert!(
!result.text.split_whitespace().any(|t| t == "e"),
"{}",
result.text
);
assert!(result.text.contains("comentaba"));
assert!(result.text.contains("fuera del aire"));
}
#[tokio::test]
async fn spanish_multi_pure_fillers() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("eh hola eee mañana").await.unwrap();
assert_eq!(result.text, "hola mañana");
}
#[tokio::test]
async fn spanish_pure_filler_case_insensitive() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("Eh hola").await.unwrap();
assert_eq!(result.text, "hola");
assert!(result.removed.contains(&"Eh".to_string()));
}
#[tokio::test]
async fn spanish_multi_word_filler_o_sea() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("o sea ahora las mujeres").await.unwrap();
assert_eq!(result.text, "ahora las mujeres");
assert!(result.removed.contains(&"o sea".to_string()));
}
#[tokio::test]
async fn spanish_token_repetition_drops_first() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("e fuera del del aire").await.unwrap();
assert_eq!(result.text, "fuera del aire");
assert!(result.removed.contains(&"e".to_string()));
assert!(result.removed.contains(&"del".to_string()));
}
#[tokio::test]
async fn spanish_three_fold_repetition_keeps_last() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("muy muy muy ligada con").await.unwrap();
assert_eq!(result.text, "muy ligada con");
}
#[tokio::test]
async fn spanish_two_token_repetition_drops_first_pair() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("a la a la casa de mi abuela").await.unwrap();
assert_eq!(result.text, "a la casa de mi abuela");
assert!(result.removed.contains(&"a la".to_string()));
}
#[tokio::test]
async fn spanish_partial_word_dropped() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("sie siempre estuvo leyendo").await.unwrap();
assert_eq!(result.text, "siempre estuvo leyendo");
assert!(result.removed.contains(&"sie".to_string()));
}
#[tokio::test]
async fn spanish_partial_stoplist_protects_function_words() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("se sevir mañana").await.unwrap();
assert_eq!(result.text, "se sevir mañana");
assert!(result.removed.is_empty());
}
#[tokio::test]
async fn spanish_repetition_of_contextual_word_dropped() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("este este libro").await.unwrap();
assert_eq!(result.text, "este libro");
assert!(result.removed.contains(&"este".to_string()));
}
#[tokio::test]
async fn spanish_clean_demonstrative_preserved() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("este libro es interesante").await.unwrap();
assert_eq!(result.text, "este libro es interesante");
assert!(result.removed.is_empty());
}
#[tokio::test]
async fn spanish_clean_text_preserved() {
let filter = SpanishFillerFilter::new();
let result = filter
.filter("como una de las partes importantes es el capitulado")
.await
.unwrap();
assert_eq!(
result.text,
"como una de las partes importantes es el capitulado"
);
assert!(result.removed.is_empty());
}
#[tokio::test]
async fn spanish_accented_characters_preserved() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("e canción mañana").await.unwrap();
assert_eq!(result.text, "canción mañana");
assert!(result.removed.contains(&"e".to_string()));
}
#[tokio::test]
async fn spanish_combined_pure_repetition_partial() {
let filter = SpanishFillerFilter::new();
let result = filter
.filter("e a la a la sie siempre estuvo")
.await
.unwrap();
assert_eq!(result.text, "a la siempre estuvo");
}
#[tokio::test]
async fn spanish_empty_text() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("").await.unwrap();
assert_eq!(result.text, "");
assert!(result.removed.is_empty());
}
fn span(start: usize, end: usize) -> crate::types::Span {
crate::types::Span { start, end }
}
#[test]
fn spanish_detect_spans_pure_filler_at_start() {
let filter = SpanishFillerFilter::new();
assert_eq!(filter.detect_spans("e fuera del aire"), vec![span(0, 1)]);
}
#[test]
fn spanish_detect_spans_token_repetition() {
let filter = SpanishFillerFilter::new();
assert_eq!(
filter.detect_spans("e fuera del del aire"),
vec![span(0, 1), span(8, 11)]
);
}
#[test]
fn spanish_detect_spans_two_token_repetition() {
let filter = SpanishFillerFilter::new();
assert_eq!(filter.detect_spans("a la a la casa"), vec![span(0, 4)]);
}
#[test]
fn spanish_detect_spans_partial_word() {
let filter = SpanishFillerFilter::new();
assert_eq!(filter.detect_spans("sie siempre estuvo"), vec![span(0, 3)]);
}
#[test]
fn spanish_detect_spans_multi_word_filler() {
let filter = SpanishFillerFilter::new();
assert_eq!(
filter.detect_spans("o sea ahora las mujeres"),
vec![span(0, 5)]
);
}
#[test]
fn spanish_detect_spans_uses_codepoint_offsets() {
let filter = SpanishFillerFilter::new();
assert_eq!(filter.detect_spans("e canción mañana"), vec![span(0, 1)]);
}
#[test]
fn spanish_detect_spans_combined() {
let filter = SpanishFillerFilter::new();
let spans = filter.detect_spans("e a la a la sie siempre estuvo");
assert_eq!(spans, vec![span(0, 1), span(2, 6), span(12, 15)]);
}
#[test]
fn spanish_detect_spans_clean_text_yields_empty() {
let filter = SpanishFillerFilter::new();
assert!(filter.detect_spans("este libro es interesante").is_empty());
}
#[test]
fn spanish_detect_spans_empty_input() {
let filter = SpanishFillerFilter::new();
assert!(filter.detect_spans("").is_empty());
assert!(filter.detect_spans(" ").is_empty());
}
#[test]
fn spanish_detect_spans_match_filter_results() {
let filter = SpanishFillerFilter::new();
let text = "e fuera del del a la a la casa";
let spans = filter.detect_spans(text);
let chars: Vec<char> = text.chars().collect();
let removed_surfaces: Vec<String> = spans
.iter()
.map(|s| chars[s.start..s.end].iter().collect())
.collect();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let result = rt.block_on(filter.filter(text)).unwrap();
let mut a = removed_surfaces.clone();
let mut b = result.removed.clone();
a.sort();
b.sort();
assert_eq!(a, b);
}
#[tokio::test]
async fn korean_pure_filler_removed_at_start() {
let filter = SimpleFillerFilter::korean();
let result = filter.filter("음 회의 시간을 변경합시다").await.unwrap();
assert_eq!(result.text, "회의 시간을 변경합시다");
assert!(result.removed.contains(&"음".to_string()));
}
#[tokio::test]
async fn korean_pure_filler_removed_mid_utterance() {
let filter = SimpleFillerFilter::korean();
let result = filter.filter("회의 시간을 어 변경합시다").await.unwrap();
assert!(
!result.text.split_whitespace().any(|t| t == "어"),
"{}",
result.text
);
assert!(result.text.contains("회의"));
assert!(result.text.contains("변경합시다"));
}
#[tokio::test]
async fn korean_contextual_filler_removed_at_sentence_start() {
let filter = SimpleFillerFilter::korean();
let result = filter.filter("그러니까 다음 주에 만나자").await.unwrap();
assert_eq!(result.text, "다음 주에 만나자");
}
#[tokio::test]
async fn korean_contextual_filler_kept_mid_utterance() {
let filter = SimpleFillerFilter::korean();
let result = filter.filter("나는 그 사람을 만났다").await.unwrap();
assert!(result.text.contains("그 사람"), "{}", result.text);
}
#[tokio::test]
async fn korean_multi_word_filler_removed() {
let filter = SimpleFillerFilter::korean();
let result = filter.filter("음 그러니까 회의를 시작하자").await.unwrap();
assert_eq!(result.text, "회의를 시작하자");
}
#[tokio::test]
async fn korean_clean_text_unchanged() {
let filter = SimpleFillerFilter::korean();
let result = filter
.filter("내일 오후 세 시에 만나기로 했습니다")
.await
.unwrap();
assert_eq!(result.text, "내일 오후 세 시에 만나기로 했습니다");
}
#[tokio::test]
async fn japanese_filler_removal_keeps_the_utterance() {
let filter = FillerFilter::for_language(Language::Japanese);
let result = filter
.filter("えーと、今日は会議が三時からあります。")
.await
.unwrap();
assert_eq!(result.text, "今日は会議が三時からあります。");
}
#[tokio::test]
async fn chinese_filler_removal_keeps_the_utterance() {
let filter = FillerFilter::for_language(Language::Chinese);
let result = filter.filter("嗯,我们明天开会。").await.unwrap();
assert_eq!(result.text, "我们明天开会。");
}
#[tokio::test]
async fn every_language_removes_the_filler_and_keeps_the_content() {
let cases = [
(
Language::English,
"um, the meeting is at three.",
"the meeting is at three.",
),
(
Language::Japanese,
"えーと、会議は三時からです。",
"会議は三時からです。",
),
(Language::Chinese, "嗯,会议在三点开始。", "会议在三点开始。"),
(
Language::Korean,
"음 회의는 세 시에 시작합니다",
"회의는 세 시에 시작합니다",
),
(
Language::Spanish,
"eh, la reunión es a las tres.",
"la reunión es a las tres.",
),
];
for (language, input, expected) in cases {
let result = FillerFilter::for_language(language)
.filter(input)
.await
.unwrap();
assert_eq!(result.text, expected, "{language:?} on {input:?}");
assert!(
!result.removed.is_empty(),
"{language:?} removed nothing from {input:?}"
);
}
}
#[tokio::test]
async fn spanish_filler_followed_by_punctuation_is_removed() {
let filter = SpanishFillerFilter::new();
let result = filter.filter("eh, la reunión es a las tres").await.unwrap();
assert_eq!(result.text, "la reunión es a las tres");
let result = filter.filter("o sea, no lo sé").await.unwrap();
assert_eq!(result.text, "no lo sé");
let result = filter.filter("e, fuera del aire").await.unwrap();
assert_eq!(result.text, "fuera del aire");
}
#[tokio::test]
async fn filler_free_text_is_untouched() {
let cases = [
(Language::English, "the meeting is at three."),
(Language::Japanese, "今日は良い天気ですね。"),
(Language::Chinese, "今天天气很好。"),
(Language::Korean, "오늘 날씨가 좋습니다"),
(Language::Spanish, "la reunión es a las tres."),
];
for (language, input) in cases {
let result = FillerFilter::for_language(language)
.filter(input)
.await
.unwrap();
assert_eq!(result.text, input, "{language:?} altered filler-free text");
}
}
#[test]
fn for_language_picks_the_matching_segmentation() {
assert!(matches!(
FillerFilter::for_language(Language::Japanese),
FillerFilter::Japanese(_)
));
assert!(matches!(
FillerFilter::for_language(Language::Chinese),
FillerFilter::Chinese(_)
));
assert!(matches!(
FillerFilter::for_language(Language::Spanish),
FillerFilter::Spanish(_)
));
assert!(matches!(
FillerFilter::for_language(Language::English),
FillerFilter::Word(_)
));
assert!(matches!(
FillerFilter::for_language(Language::Korean),
FillerFilter::Word(_)
));
}
#[test]
fn bcp47_round_trips_and_rejects_unsupported() {
for language in [
Language::English,
Language::Japanese,
Language::Chinese,
Language::Korean,
Language::Spanish,
] {
assert_eq!(Language::from_bcp47(language.as_bcp47()), Some(language));
}
assert_eq!(Language::from_bcp47("en-US"), Some(Language::English));
assert_eq!(Language::from_bcp47("zh-Hans"), Some(Language::Chinese));
assert_eq!(Language::from_bcp47("JA"), Some(Language::Japanese));
assert_eq!(Language::from_bcp47("de"), None);
assert_eq!(Language::from_bcp47(""), None);
}
}