Skip to main content

parla_clean/clean/
rules.rs

1//! Regras de limpeza como DADOS (ADR-0010).
2//!
3//! Toda muleta é uma instância de [`FillerRule`] na tabela
4//! [`DEFAULT_FILLERS`] — não existe `match` de regra espalhado em código.
5//! Adicionar/ajustar uma muleta, uma guarda ou uma variante nunca toca em
6//! lógica: é editar uma linha de dados (a fonte pt-BR é o ADR-0009:
7//! C-ORAL-BRASIL/UFS e Uh-Mazing 2026).
8//!
9//! [`KNOWN_VARIANTS`] é tabela compartilhada com o prompt de nuvem no
10//! Parla (a aplicação de onde esta crate foi extraída) — uma fonte,
11//! dois consumidores.
12//!
13//! Decisões deliberadas (ver ADR-0010):
14//! - tabelas `const` em vez de TOML+serde: o usuário edita regras pela UI
15//!   de vocabulário existente, e um arquivo de regras sem UI seria peso
16//!   morto (a dependência `toml` não entra);
17//! - comparação de caixa SEMPRE via [`fold`] (Unicode completo) — nunca
18//!   `eq_ignore_ascii_case` nem `.to_lowercase().next()` (bug 2/3 da v1).
19
20/// Posição sintática que uma muleta pode ocupar para ser removida.
21/// Fronteira = início do texto, fim do texto ou vizinho não-palavra
22/// (pontuação, número, URL, e-mail, símbolo).
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Position {
25    /// Fronteira antes (início do texto ou após pontuação).
26    Start,
27    /// Fronteira depois (fim do texto ou antes de pontuação).
28    End,
29    /// Fronteira dos dois lados.
30    Isolated,
31}
32
33/// Regra declarativa de muleta. Tudo o que o removedor precisa saber sobre
34/// uma palavra está aqui.
35#[derive(Debug, Clone, Copy)]
36pub struct FillerRule {
37    /// Forma lowercase da muleta.
38    pub word: &'static str,
39    /// Posições aceitas.
40    pub positions: &'static [Position],
41    /// Guardas: se o token anterior for um destes, NÃO remove.
42    pub not_if_prev: &'static [&'static str],
43    /// Guardas: se o próximo for um destes, NÃO remove.
44    pub not_if_next: &'static [&'static str],
45    /// Remove também quando o anterior é um destes ("assim" após "tipo"),
46    /// sem exigir fronteira — as guardas continuam valendo.
47    pub compound_prev: &'static [&'static str],
48    /// Remove quando o próximo é um destes ("tipo" formando "tipo assim").
49    /// As guardas continuam valendo.
50    pub compound_next: &'static [&'static str],
51    /// Exige fronteira ANTES para casar `compound_next` ("né não" só cai
52    /// após vírgula/borda; "é tipo assim" cai no meio da frase).
53    pub compound_requires_boundary_before: bool,
54    /// OBRIGA o token anterior a ser um destes ("não" só após "né").
55    pub requires_prev: &'static [&'static str],
56    /// "olha": exige vírgula logo depois.
57    pub requires_after_comma: bool,
58    /// Pontuação terminal ('.'/'!'/'?') pertence à FRASE: "disso né?" vira
59    /// "Disso?" (a pergunta sobrevive). False = tag declarativa ("sabe?",
60    /// "tá?"): a pontuação é da tag e a frase ganha ponto final.
61    /// (Correção do bug 1 da v1 — regra por regra, não receita cega.)
62    pub keep_terminal: bool,
63}
64
65/// Regras padrão pt-BR. Fonte: ADR-0009 (C-ORAL-BRASIL/UFS, Uh-Mazing
66/// 2026). Sempre em ordem de itens; o removedor não depende de ordem.
67pub const DEFAULT_FILLERS: &[FillerRule] = &[
68    FillerRule {
69        word: "tipo",
70        positions: &[Position::Isolated],
71        not_if_prev: &[],
72        // "que TIPO de", "um TIPO de": nominal, nunca cai
73        not_if_next: &["de", "que"],
74        compound_prev: &[],
75        // "tipo assim" (expressão = muleta por definição)
76        compound_next: &["assim"],
77        compound_requires_boundary_before: false,
78        requires_prev: &[],
79        requires_after_comma: false,
80        keep_terminal: false,
81    },
82    FillerRule {
83        word: "assim",
84        positions: &[Position::Isolated],
85        not_if_prev: &[
86            "mesmo", "mesma", "é", "e", "faz", "fazer", "fez", "fica", "ficar",
87            "dessa", "deste", "desta", "daquele", "daquela", "aquele", "aquela",
88            "aquilo", "bem",
89        ],
90        // "ASSIM como", "ASSIM que": forma adverbial funcional
91        not_if_next: &["como", "que"],
92        // "tipo assim": o "assim" cai junto com o "tipo"
93        compound_prev: &["tipo"],
94        compound_next: &[],
95        compound_requires_boundary_before: false,
96        requires_prev: &[],
97        requires_after_comma: false,
98        keep_terminal: false,
99    },
100    FillerRule {
101        word: "né",
102        positions: &[Position::End],
103        not_if_prev: &[],
104        not_if_next: &[],
105        compound_prev: &[],
106        // "né não" — tag dupla; exige fronteira antes ("certo, né não?")
107        compound_next: &["não"],
108        compound_requires_boundary_before: true,
109        requires_prev: &[],
110        requires_after_comma: false,
111        // "disso né?" É pergunta real — o '?' pertence à frase
112        keep_terminal: true,
113    },
114    FillerRule {
115        word: "não",
116        positions: &[Position::End],
117        not_if_prev: &[],
118        not_if_next: &[],
119        compound_prev: &[],
120        compound_next: &[],
121        compound_requires_boundary_before: false,
122        // cauda do marcador duplo: SÓ remove após "né"
123        requires_prev: &["né"],
124        requires_after_comma: false,
125        // "certo, né não?" — herda a interrogação do par
126        keep_terminal: true,
127    },
128    FillerRule {
129        word: "então",
130        positions: &[Position::Isolated],
131        not_if_prev: &[],
132        not_if_next: &[],
133        compound_prev: &[],
134        compound_next: &[],
135        compound_requires_boundary_before: false,
136        requires_prev: &[],
137        requires_after_comma: false,
138        keep_terminal: false,
139    },
140    FillerRule {
141        word: "sabe",
142        positions: &[Position::Isolated],
143        not_if_prev: &[],
144        not_if_next: &[],
145        compound_prev: &[],
146        compound_next: &[],
147        compound_requires_boundary_before: false,
148        requires_prev: &[],
149        requires_after_comma: false,
150        // tag declarativa: "o projeto está bom, sabe?" → "O projeto está bom."
151        keep_terminal: false,
152    },
153    FillerRule {
154        word: "entendeu",
155        positions: &[Position::Isolated],
156        not_if_prev: &[],
157        not_if_next: &[],
158        compound_prev: &[],
159        compound_next: &[],
160        compound_requires_boundary_before: false,
161        requires_prev: &[],
162        requires_after_comma: false,
163        keep_terminal: false,
164    },
165    FillerRule {
166        word: "viu",
167        positions: &[Position::Isolated],
168        not_if_prev: &[],
169        not_if_next: &[],
170        compound_prev: &[],
171        compound_next: &[],
172        compound_requires_boundary_before: false,
173        requires_prev: &[],
174        requires_after_comma: false,
175        keep_terminal: false,
176    },
177    FillerRule {
178        word: "tá",
179        positions: &[Position::Isolated],
180        not_if_prev: &[],
181        // "tá": isolado por pontuação/bordas. "tá bom", "tá vendo",
182        // "tá certo" e o verbo "está" ("ela tá cansada", "ele tá.")
183        // sobrevivem por NÃO ESTAREM ISOLADOS (têm palavra depois).
184        // Não há not_if_next porque a Position::Isolated já basta.
185        not_if_next: &[],
186        compound_prev: &[],
187        compound_next: &[],
188        compound_requires_boundary_before: false,
189        requires_prev: &[],
190        requires_after_comma: false,
191        keep_terminal: false,
192    },
193    FillerRule {
194        word: "olha",
195        positions: &[Position::Start],
196        not_if_prev: &[],
197        not_if_next: &[],
198        compound_prev: &[],
199        compound_next: &[],
200        compound_requires_boundary_before: false,
201        requires_prev: &[],
202        // "olha, isso" cai; "olha isso aqui" (imperativo) fica
203        requires_after_comma: true,
204        keep_terminal: false,
205    },
206];
207
208/// Variantes de grafia já observadas por termo do vocabulário (regressões
209/// reais): o whisper transcreve "Sam Altman" como "sematlman"/"semautman"/
210/// "samautiman", "Claude Code" como "cloud code"/"cloude code", "GitHub"
211/// como "git hub"/"github". A correção local casa QUALQUER variante listada
212/// (ignorando caixa e espaços) quando o termo está no vocabulário do
213/// usuário; variantes de termo removido nunca entram. Tabela compartilhada
214/// com o prompt de nuvem (`groq.rs`) — uma fonte só, dois consumidores.
215pub const KNOWN_VARIANTS: &[(&str, &[&str])] = &[
216    ("Sam Altman", &["sematlman", "semautman", "samautiman"]),
217    ("Claude Code", &["cloud code", "cloude code", "claud code"]),
218    ("GitHub", &["github", "git hub"]),
219    ("ChatGPT", &["chatgpt", "chat gpt"]),
220    ("OpenAI", &["openai", "open ia", "open ai"]),
221    ("Anthropic", &["antropic", "antropico", "antrópico"]),
222];
223
224/// Palavras funcionais onde duplicação consecutiva é quase sempre erro do
225/// ASR ("eu eu", "o o", "e e", "de de") — em pt-BR escrito não existe
226/// reduplicação legítima dessas formas.
227pub const FUNCTION_WORDS: &[&str] = &[
228    "a", "à", "ao", "aos", "as", "com", "da", "das", "de", "do", "dos", "e",
229    "é", "em", "entre", "eu", "já", "mas", "na", "nas", "no", "nos", "o",
230    "os", "ou", "para", "por", "que", "se", "sem", "sim", "te", "tu", "um",
231    "uma", "uns", "umas", "vou", "vamos", "não", "nao", "me", "lhe",
232];
233
234/// Lowercase Unicode COMPLETO (não-ASCII inclusivo). Nunca use
235/// `eq_ignore_ascii_case` — "JOSÉ" ≠ "josé" naquela comparação.
236pub fn fold(s: &str) -> String {
237    s.to_lowercase()
238}
239
240/// Lowercase completo com espaços removidos — forma canônica para casar
241/// variantes multi-palavra ("git hub" ≡ "github" ≡ "Git Hub").
242pub fn fold_flat(s: &str) -> String {
243    s.to_lowercase().chars().filter(|c| !c.is_whitespace()).collect()
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn fold_handles_accents_and_multi_char_lowercase() {
252        assert_eq!(fold("JOSÉ SÃO PAULO"), "josé são paulo");
253        // lowercase de ẞ é ß (o "ss" seria casefolding, não lowercase)
254        assert_eq!(fold("ẞ"), "ß");
255        // İ (U+0130) baixa para DOIS chars ("i" + ponto combinável) —
256        // é por isso que comparar char-a-char com .next() quebra (bug 2)
257        assert_eq!(fold("İ"), "i\u{307}");
258        assert_ne!(fold("İ"), "i");
259    }
260
261    #[test]
262    fn fold_flat_joins_words() {
263        assert_eq!(fold_flat("Git Hub"), "github");
264        assert_eq!(fold_flat("  Sam\tAltman "), "samaltman");
265    }
266
267    #[test]
268    fn default_fillers_are_all_lowercase_single_words() {
269        for r in DEFAULT_FILLERS {
270            assert_eq!(r.word, r.word.to_lowercase(), "regra '{}' fora do padrão", r.word);
271            assert!(!r.word.contains(char::is_whitespace), "regra '{}' multi-palavra", r.word);
272        }
273    }
274
275    #[test]
276    fn every_filler_rule_has_a_home_in_the_table() {
277        // as 10 muletas documentadas no ADR-0009 estão todas na tabela
278        let words: Vec<&str> = DEFAULT_FILLERS.iter().map(|r| r.word).collect();
279        for w in ["tipo", "assim", "né", "não", "então", "sabe", "entendeu", "viu", "tá", "olha"] {
280            assert!(words.contains(&w), "muleta '{}' ausente da tabela", w);
281        }
282    }
283}