parla-clean 0.1.0

Deterministic post-processing for Brazilian Portuguese speech transcription: filler removal, vocabulary variant correction, ASR deduplication
Documentation
//! Regras de limpeza como DADOS (ADR-0010).
//!
//! Toda muleta é uma instância de [`FillerRule`] na tabela
//! [`DEFAULT_FILLERS`] — não existe `match` de regra espalhado em código.
//! Adicionar/ajustar uma muleta, uma guarda ou uma variante nunca toca em
//! lógica: é editar uma linha de dados (a fonte pt-BR é o ADR-0009:
//! C-ORAL-BRASIL/UFS e Uh-Mazing 2026).
//!
//! [`KNOWN_VARIANTS`] é tabela compartilhada com o prompt de nuvem no
//! Parla (a aplicação de onde esta crate foi extraída) — uma fonte,
//! dois consumidores.
//!
//! Decisões deliberadas (ver ADR-0010):
//! - tabelas `const` em vez de TOML+serde: o usuário edita regras pela UI
//!   de vocabulário existente, e um arquivo de regras sem UI seria peso
//!   morto (a dependência `toml` não entra);
//! - comparação de caixa SEMPRE via [`fold`] (Unicode completo) — nunca
//!   `eq_ignore_ascii_case` nem `.to_lowercase().next()` (bug 2/3 da v1).

/// Posição sintática que uma muleta pode ocupar para ser removida.
/// Fronteira = início do texto, fim do texto ou vizinho não-palavra
/// (pontuação, número, URL, e-mail, símbolo).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Position {
    /// Fronteira antes (início do texto ou após pontuação).
    Start,
    /// Fronteira depois (fim do texto ou antes de pontuação).
    End,
    /// Fronteira dos dois lados.
    Isolated,
}

/// Regra declarativa de muleta. Tudo o que o removedor precisa saber sobre
/// uma palavra está aqui.
#[derive(Debug, Clone, Copy)]
pub struct FillerRule {
    /// Forma lowercase da muleta.
    pub word: &'static str,
    /// Posições aceitas.
    pub positions: &'static [Position],
    /// Guardas: se o token anterior for um destes, NÃO remove.
    pub not_if_prev: &'static [&'static str],
    /// Guardas: se o próximo for um destes, NÃO remove.
    pub not_if_next: &'static [&'static str],
    /// Remove também quando o anterior é um destes ("assim" após "tipo"),
    /// sem exigir fronteira — as guardas continuam valendo.
    pub compound_prev: &'static [&'static str],
    /// Remove quando o próximo é um destes ("tipo" formando "tipo assim").
    /// As guardas continuam valendo.
    pub compound_next: &'static [&'static str],
    /// Exige fronteira ANTES para casar `compound_next` ("né não" só cai
    /// após vírgula/borda; "é tipo assim" cai no meio da frase).
    pub compound_requires_boundary_before: bool,
    /// OBRIGA o token anterior a ser um destes ("não" só após "né").
    pub requires_prev: &'static [&'static str],
    /// "olha": exige vírgula logo depois.
    pub requires_after_comma: bool,
    /// Pontuação terminal ('.'/'!'/'?') pertence à FRASE: "disso né?" vira
    /// "Disso?" (a pergunta sobrevive). False = tag declarativa ("sabe?",
    /// "tá?"): a pontuação é da tag e a frase ganha ponto final.
    /// (Correção do bug 1 da v1 — regra por regra, não receita cega.)
    pub keep_terminal: bool,
}

/// Regras padrão pt-BR. Fonte: ADR-0009 (C-ORAL-BRASIL/UFS, Uh-Mazing
/// 2026). Sempre em ordem de itens; o removedor não depende de ordem.
pub const DEFAULT_FILLERS: &[FillerRule] = &[
    FillerRule {
        word: "tipo",
        positions: &[Position::Isolated],
        not_if_prev: &[],
        // "que TIPO de", "um TIPO de": nominal, nunca cai
        not_if_next: &["de", "que"],
        compound_prev: &[],
        // "tipo assim" (expressão = muleta por definição)
        compound_next: &["assim"],
        compound_requires_boundary_before: false,
        requires_prev: &[],
        requires_after_comma: false,
        keep_terminal: false,
    },
    FillerRule {
        word: "assim",
        positions: &[Position::Isolated],
        not_if_prev: &[
            "mesmo", "mesma", "é", "e", "faz", "fazer", "fez", "fica", "ficar",
            "dessa", "deste", "desta", "daquele", "daquela", "aquele", "aquela",
            "aquilo", "bem",
        ],
        // "ASSIM como", "ASSIM que": forma adverbial funcional
        not_if_next: &["como", "que"],
        // "tipo assim": o "assim" cai junto com o "tipo"
        compound_prev: &["tipo"],
        compound_next: &[],
        compound_requires_boundary_before: false,
        requires_prev: &[],
        requires_after_comma: false,
        keep_terminal: false,
    },
    FillerRule {
        word: "",
        positions: &[Position::End],
        not_if_prev: &[],
        not_if_next: &[],
        compound_prev: &[],
        // "né não" — tag dupla; exige fronteira antes ("certo, né não?")
        compound_next: &["não"],
        compound_requires_boundary_before: true,
        requires_prev: &[],
        requires_after_comma: false,
        // "disso né?" É pergunta real — o '?' pertence à frase
        keep_terminal: true,
    },
    FillerRule {
        word: "não",
        positions: &[Position::End],
        not_if_prev: &[],
        not_if_next: &[],
        compound_prev: &[],
        compound_next: &[],
        compound_requires_boundary_before: false,
        // cauda do marcador duplo: SÓ remove após "né"
        requires_prev: &[""],
        requires_after_comma: false,
        // "certo, né não?" — herda a interrogação do par
        keep_terminal: true,
    },
    FillerRule {
        word: "então",
        positions: &[Position::Isolated],
        not_if_prev: &[],
        not_if_next: &[],
        compound_prev: &[],
        compound_next: &[],
        compound_requires_boundary_before: false,
        requires_prev: &[],
        requires_after_comma: false,
        keep_terminal: false,
    },
    FillerRule {
        word: "sabe",
        positions: &[Position::Isolated],
        not_if_prev: &[],
        not_if_next: &[],
        compound_prev: &[],
        compound_next: &[],
        compound_requires_boundary_before: false,
        requires_prev: &[],
        requires_after_comma: false,
        // tag declarativa: "o projeto está bom, sabe?" → "O projeto está bom."
        keep_terminal: false,
    },
    FillerRule {
        word: "entendeu",
        positions: &[Position::Isolated],
        not_if_prev: &[],
        not_if_next: &[],
        compound_prev: &[],
        compound_next: &[],
        compound_requires_boundary_before: false,
        requires_prev: &[],
        requires_after_comma: false,
        keep_terminal: false,
    },
    FillerRule {
        word: "viu",
        positions: &[Position::Isolated],
        not_if_prev: &[],
        not_if_next: &[],
        compound_prev: &[],
        compound_next: &[],
        compound_requires_boundary_before: false,
        requires_prev: &[],
        requires_after_comma: false,
        keep_terminal: false,
    },
    FillerRule {
        word: "",
        positions: &[Position::Isolated],
        not_if_prev: &[],
        // "tá": isolado por pontuação/bordas. "tá bom", "tá vendo",
        // "tá certo" e o verbo "está" ("ela tá cansada", "ele tá.")
        // sobrevivem por NÃO ESTAREM ISOLADOS (têm palavra depois).
        // Não há not_if_next porque a Position::Isolated já basta.
        not_if_next: &[],
        compound_prev: &[],
        compound_next: &[],
        compound_requires_boundary_before: false,
        requires_prev: &[],
        requires_after_comma: false,
        keep_terminal: false,
    },
    FillerRule {
        word: "olha",
        positions: &[Position::Start],
        not_if_prev: &[],
        not_if_next: &[],
        compound_prev: &[],
        compound_next: &[],
        compound_requires_boundary_before: false,
        requires_prev: &[],
        // "olha, isso" cai; "olha isso aqui" (imperativo) fica
        requires_after_comma: true,
        keep_terminal: false,
    },
];

/// Variantes de grafia já observadas por termo do vocabulário (regressões
/// reais): o whisper transcreve "Sam Altman" como "sematlman"/"semautman"/
/// "samautiman", "Claude Code" como "cloud code"/"cloude code", "GitHub"
/// como "git hub"/"github". A correção local casa QUALQUER variante listada
/// (ignorando caixa e espaços) quando o termo está no vocabulário do
/// usuário; variantes de termo removido nunca entram. Tabela compartilhada
/// com o prompt de nuvem (`groq.rs`) — uma fonte só, dois consumidores.
pub const KNOWN_VARIANTS: &[(&str, &[&str])] = &[
    ("Sam Altman", &["sematlman", "semautman", "samautiman"]),
    ("Claude Code", &["cloud code", "cloude code", "claud code"]),
    ("GitHub", &["github", "git hub"]),
    ("ChatGPT", &["chatgpt", "chat gpt"]),
    ("OpenAI", &["openai", "open ia", "open ai"]),
    ("Anthropic", &["antropic", "antropico", "antrópico"]),
];

/// Palavras funcionais onde duplicação consecutiva é quase sempre erro do
/// ASR ("eu eu", "o o", "e e", "de de") — em pt-BR escrito não existe
/// reduplicação legítima dessas formas.
pub const FUNCTION_WORDS: &[&str] = &[
    "a", "à", "ao", "aos", "as", "com", "da", "das", "de", "do", "dos", "e",
    "é", "em", "entre", "eu", "", "mas", "na", "nas", "no", "nos", "o",
    "os", "ou", "para", "por", "que", "se", "sem", "sim", "te", "tu", "um",
    "uma", "uns", "umas", "vou", "vamos", "não", "nao", "me", "lhe",
];

/// Lowercase Unicode COMPLETO (não-ASCII inclusivo). Nunca use
/// `eq_ignore_ascii_case` — "JOSÉ" ≠ "josé" naquela comparação.
pub fn fold(s: &str) -> String {
    s.to_lowercase()
}

/// Lowercase completo com espaços removidos — forma canônica para casar
/// variantes multi-palavra ("git hub" ≡ "github" ≡ "Git Hub").
pub fn fold_flat(s: &str) -> String {
    s.to_lowercase().chars().filter(|c| !c.is_whitespace()).collect()
}

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

    #[test]
    fn fold_handles_accents_and_multi_char_lowercase() {
        assert_eq!(fold("JOSÉ SÃO PAULO"), "josé são paulo");
        // lowercase de ẞ é ß (o "ss" seria casefolding, não lowercase)
        assert_eq!(fold(""), "ß");
        // İ (U+0130) baixa para DOIS chars ("i" + ponto combinável) —
        // é por isso que comparar char-a-char com .next() quebra (bug 2)
        assert_eq!(fold("İ"), "i\u{307}");
        assert_ne!(fold("İ"), "i");
    }

    #[test]
    fn fold_flat_joins_words() {
        assert_eq!(fold_flat("Git Hub"), "github");
        assert_eq!(fold_flat("  Sam\tAltman "), "samaltman");
    }

    #[test]
    fn default_fillers_are_all_lowercase_single_words() {
        for r in DEFAULT_FILLERS {
            assert_eq!(r.word, r.word.to_lowercase(), "regra '{}' fora do padrão", r.word);
            assert!(!r.word.contains(char::is_whitespace), "regra '{}' multi-palavra", r.word);
        }
    }

    #[test]
    fn every_filler_rule_has_a_home_in_the_table() {
        // as 10 muletas documentadas no ADR-0009 estão todas na tabela
        let words: Vec<&str> = DEFAULT_FILLERS.iter().map(|r| r.word).collect();
        for w in ["tipo", "assim", "", "não", "então", "sabe", "entendeu", "viu", "", "olha"] {
            assert!(words.contains(&w), "muleta '{}' ausente da tabela", w);
        }
    }
}