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}