parla-clean 0.1.0

Deterministic post-processing for Brazilian Portuguese speech transcription: filler removal, vocabulary variant correction, ASR deduplication
Documentation
//! Tokenização rica e lossless (ADR-0010).
//!
//! Uma passada só, máquina de estados: palavras, números (com separadores
//! pt-BR: "1.000,50", "14h30"), URLs/domínios, e-mails, pontuação e
//! espaços. [`TokenStream::render`] devolve o texto original byte a byte —
//! nada se perde (invariante I3). As passadas de limpeza operam sobre os
//! tokens e NUNCA re-tokenizam.
//!
//! Por que tipos ricos importam (regressões reais da v1): "<https://tipo.com>"
//! tinha o "tipo" interno tratado como muleta e corrompia a URL; "14h30
//! 14h30" não era deduplicado porque os tokens alfabéticos ("h") quebravam
//! a repetição. Aqui a URL é UM token e "14h30" é UM número.

/// Tipo do token. A fronteira de palavra das regras usa estes tipos:
/// `Word`/`Number`/`Url`/`Email` NÃO são fronteira; todo o resto é.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
    /// Run contíguo de letras (Unicode-aware, acentos incluídos).
    Word,
    /// Número com separadores pt-BR ("1.000,50", "14h30", "3-5").
    Number,
    /// URL com esquema ("https://...") ou domínio ("tipo.com", "www.x.com").
    Url,
    /// Endereço de e-mail ("fulano@sabe.com").
    Email,
    /// Pontuação ASCII (um token por caractere).
    Punct,
    /// Espaços (qualquer char `is_whitespace`).
    Whitespace,
    /// Qualquer outra coisa (emoji, símbolos, travessões) — run contíguo.
    Other,
}

/// Token com posição byte no texto original (para spans de deleção).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
    pub kind: TokenKind,
    pub text: String,
    pub start: usize,
    pub end: usize,
}

/// Stream de tokens de um texto. A ordem preserva o original.
#[derive(Debug, Clone, Default)]
pub struct TokenStream {
    pub tokens: Vec<Token>,
}

impl TokenStream {
    /// Tokeniza `input` em uma passada. Lossless: [`Self::render`] de um
    /// stream produz exatamente o input.
    pub fn tokenize(input: &str) -> TokenStream {
        let chars: Vec<(usize, char)> = input.char_indices().collect();
        let mut tokens = Vec::new();
        let mut i = 0usize;
        while i < chars.len() {
            let (start, c) = chars[i];
            let (kind, end) = if c.is_whitespace() {
                let end = run_while(&chars, i, |c| c.is_whitespace());
                (TokenKind::Whitespace, end)
            } else if c.is_alphabetic() {
                // pontuação sentencial colada ("tipo.com,", "a@b.com.")
                // pertence à frase, não ao link — o run do link é aparado
                let end = link_end(&chars, i);
                match classify_link(&chars, i, end) {
                    LinkKind::Url => (TokenKind::Url, end),
                    LinkKind::Email => (TokenKind::Email, end),
                    LinkKind::None => {
                        let end = run_while(&chars, i, |c| c.is_alphabetic());
                        (TokenKind::Word, end)
                    }
                }
            } else if c.is_numeric() {
                (TokenKind::Number, number_end(&chars, i))
            } else if c.is_ascii_punctuation() {
                (TokenKind::Punct, i + 1)
            } else {
                let end = run_while(&chars, i, |c| {
                    !c.is_whitespace() && !c.is_alphanumeric() && !c.is_ascii_punctuation()
                });
                (TokenKind::Other, end)
            };
            let end_idx = if end >= chars.len() {
                input.len()
            } else {
                chars[end].0
            };
            tokens.push(Token {
                kind,
                text: input[start..end_idx].to_string(),
                start,
                end: end_idx,
            });
            i = end;
        }
        TokenStream { tokens }
    }

    /// Reconstitui o texto original: concatenação dos tokens, na ordem.
    /// Byte a byte — invariante I3.
    pub fn render(&self) -> String {
        let mut out = String::new();
        for t in &self.tokens {
            out.push_str(&t.text);
        }
        out
    }

    /// Token significativo anterior (pulando só espaços).
    pub fn prev_significant(&self, i: usize) -> Option<&Token> {
        self.tokens[..i]
            .iter()
            .rev()
            .find(|t| t.kind != TokenKind::Whitespace)
    }

    /// Token significativo seguinte (pulando só espaços).
    pub fn next_significant(&self, i: usize) -> Option<&Token> {
        self.tokens[i + 1..]
            .iter()
            .find(|t| t.kind != TokenKind::Whitespace)
    }
}

fn run_while(chars: &[(usize, char)], mut i: usize, pred: impl Fn(char) -> bool) -> usize {
    while i < chars.len() && pred(chars[i].1) {
        i += 1;
    }
    i
}

fn run_until_whitespace(chars: &[(usize, char)], i: usize) -> usize {
    run_while(chars, i, |c| !c.is_whitespace())
}

/// Fim de um token Number. Consome dígitos e separadores pt-BR:
/// "1.000,50", "14h30", "3-5", "42". O separador só entra quando está
/// ENTRE dígitos ("15," para na vírgula — ela vira Punct e isola muletas).
fn number_end(chars: &[(usize, char)], start: usize) -> usize {
    let mut i = start;
    let mut consumed_digit = false;
    while i < chars.len() {
        let c = chars[i].1;
        if c.is_numeric() {
            consumed_digit = true;
            i += 1;
        } else if consumed_digit
            && matches!(c, '.' | ',' | ':' | '/' | '-')
            && chars.get(i + 1).is_some_and(|(_, n)| n.is_numeric())
        {
            i += 1;
        } else if consumed_digit
            && c == 'h'
            && chars.get(i + 1).is_some_and(|(_, n)| n.is_numeric())
        {
            // hora "14h30": o 'h' só entra entre dígitos
            i += 1;
        } else {
            break;
        }
    }
    i
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinkKind {
    None,
    Url,
    Email,
}

/// Fim do run de um possível link: até o whitespace, menos pontuação
/// colada (",", ".", "!", "?", ";", ":", ")", "]", "}") que pertence à
/// frase.
fn link_end(chars: &[(usize, char)], i: usize) -> usize {
    let mut end = run_until_whitespace(chars, i);
    while end > i && matches!(chars[end - 1].1, ',' | '.' | '!' | '?' | ';' | ':' | ')' | ']' | '}') {
        end -= 1;
    }
    end
}

/// Classifica o run `chars[i..end]` como URL/domínio ou e-mail — protege
/// muletas dentro de links ("tipo" em "https://tipo.com" NUNCA é removível).
///
/// O heurístico de domínio é CONSERVADOR de propósito (regressão real
/// achada por teste de propriedade): exige TLD plausível (2–6 letras
/// ASCII após o último ponto) e rejeita pontuação sentencial e travessão —
/// "ZB.úêGúEBT9h" (lixo aleatório com ponto) NUNCA vira domínio, senão a
/// tokenização deixa de ser estável entre passadas e a limpeza perde a
/// idempotência.
fn classify_link(chars: &[(usize, char)], i: usize, end: usize) -> LinkKind {
    let run: String = chars[i..end].iter().map(|(_, c)| *c).collect();
    if run.starts_with("http://") || run.starts_with("https://") || run.starts_with("www.") {
        return LinkKind::Url;
    }
    if let Some(at) = run.find('@') {
        let has_dot_after_at = run[at + 1..].contains('.');
        let alnum_before = run[..at].chars().any(|c| c.is_alphanumeric());
        if has_dot_after_at && alnum_before {
            return LinkKind::Email;
        }
        return LinkKind::None;
    }
    // domínio simples "tipo.com.br": exige TLD plausível depois do último
    // ponto e NENHUMA pontuação sentencial nem travessão no run.
    // TLD 2..=20: TLDs reais de 2026 passam (.technology, .consulting,
    // .photography, .international...); o teto de 6 da primeira versão era
    // conservador demais (re-revisão externa) — o lixo continua rejeitado
    // pelas outras guardas (exigência ASCII-alfabética + sem pontuação).
    if let Some(dot) = run.rfind('.') {
        // segmento após o último ponto, truncado no primeiro '/'
        let rest = &run[dot + 1..];
        let tld = rest.split('/').next().unwrap_or("");
        let tld_ok = (2..=20).contains(&tld.len()) && tld.chars().all(|c| c.is_ascii_alphabetic());
        let has_before = run[..dot].chars().any(|c| c.is_alphanumeric());
        let no_junk = !run.contains([',', '?', '!', ';', ':', '(', ')', '']);
        if has_before && tld_ok && no_junk {
            return LinkKind::Url;
        }
    }
    LinkKind::None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn kinds(input: &str) -> Vec<(TokenKind, String)> {
        TokenStream::tokenize(input)
            .tokens
            .iter()
            .map(|t| (t.kind, t.text.clone()))
            .collect()
    }

    fn k(kind: TokenKind, text: &str) -> (TokenKind, String) {
        (kind, text.to_string())
    }

    #[test]
    fn tokenizes_words_punct_and_spaces() {
        assert_eq!(
            kinds("tipo, o Sam"),
            vec![
                k(TokenKind::Word, "tipo"),
                k(TokenKind::Punct, ","),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "o"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "Sam"),
            ]
        );
    }

    #[test]
    fn tokenizes_pt_br_numbers_with_separators() {
        assert_eq!(
            kinds("às 14h30 e 1.000,50 e 3-5 e 15,"),
            vec![
                k(TokenKind::Word, "às"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Number, "14h30"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "e"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Number, "1.000,50"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "e"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Number, "3-5"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "e"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Number, "15"),
                k(TokenKind::Punct, ","),
            ]
        );
    }

    #[test]
    fn tokenizes_urls_and_domains_as_single_tokens() {
        assert_eq!(
            kinds("https://tipo.com e www.sabe.com.br e tipo.com"),
            vec![
                k(TokenKind::Url, "https://tipo.com"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "e"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Url, "www.sabe.com.br"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "e"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Url, "tipo.com"),
            ]
        );
    }

    #[test]
    fn tokenizes_long_tld_domains() {
        // TLDs reais de 2026 (2..=20 letras): .technology, .consulting...
        assert_eq!(
            kinds("acesse semantic.technology e o site novo.consulting"),
            vec![
                k(TokenKind::Word, "acesse"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Url, "semantic.technology"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "e"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "o"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "site"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Url, "novo.consulting"),
            ]
        );
    }

    #[test]
    fn tokenizes_emails() {
        assert_eq!(
            kinds("fale com fulano@sabe.com hoje"),
            vec![
                k(TokenKind::Word, "fale"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "com"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Email, "fulano@sabe.com"),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "hoje"),
            ]
        );
    }

    #[test]
    fn sentence_period_is_not_a_domain() {
        assert_eq!(
            kinds("terminei. Hoje"),
            vec![
                k(TokenKind::Word, "terminei"),
                k(TokenKind::Punct, "."),
                k(TokenKind::Whitespace, " "),
                k(TokenKind::Word, "Hoje"),
            ]
        );
    }

    #[test]
    fn render_is_lossless() {
        let samples = [
            "tipo, o Sam Altman usa o Claude Code né?",
            "às 14h30, 1.000,50 e 3-5",
            "https://tipo.com e fulano@sabe.com",
            "oi,tudo bem — tudo sim!",
            "   espaços   e   quebras  ",
            "café com pão, mas sem açúcar!",
        ];
        for s in samples {
            assert_eq!(TokenStream::tokenize(s).render(), *s);
        }
    }

    #[test]
    fn prev_next_significant_skip_spaces() {
        let ts = TokenStream::tokenize("tipo, o Sam");
        // índice 0 = "tipo": sem anterior; próximo significativo = ","
        assert!(ts.prev_significant(0).is_none());
        assert_eq!(ts.next_significant(0).unwrap().text, ",");
        // índice 3 = "o": anterior significativo = ","; próximo = "Sam"
        assert_eq!(ts.prev_significant(3).unwrap().text, ",");
        assert_eq!(ts.next_significant(3).unwrap().text, "Sam");
        assert!(ts.next_significant(5).is_none());
    }

    #[test]
    fn unicode_and_emoji_do_not_panic() {
        let ts = TokenStream::tokenize("İstanbul ẞ ǰ café 🎉 combinado\u{301} a\u{301}");
        assert_eq!(ts.render(), "İstanbul ẞ ǰ café 🎉 combinado\u{301} a\u{301}");
    }
}