Skip to main content

aurum_core/cleanup/
rules.rs

1//! On-device rule-based cleanup (JOE-1610).
2//!
3//! No network, no ML weights — pure string/regex transforms.
4//! English filler/contraction heuristics apply only when language is English
5//! (or `auto` defaulting to English). Other languages receive whitespace-safe
6//! normalization only for `clean`/`professional` styles.
7
8use super::{CleanupProviderKind, CleanupResult, CleanupStyle, TextCleanup};
9use crate::error::Result;
10use async_trait::async_trait;
11use regex::Regex;
12use std::sync::LazyLock;
13
14/// Local, deterministic text cleanup.
15#[derive(Debug, Default, Clone)]
16pub struct RulesCleanup {
17    /// Optional BCP-47 / ISO language hint (e.g. `en`, `fr`, `auto`).
18    pub language: Option<String>,
19}
20
21impl RulesCleanup {
22    pub fn new() -> Self {
23        Self { language: None }
24    }
25
26    pub fn with_language(mut self, language: impl Into<String>) -> Self {
27        self.language = Some(language.into());
28        self
29    }
30
31    fn is_english(&self) -> bool {
32        match self
33            .language
34            .as_deref()
35            .map(|s| s.trim().to_ascii_lowercase())
36        {
37            None => true, // historical default: English heuristics
38            Some(l)
39                if l.is_empty()
40                    || l == "auto"
41                    || l == "en"
42                    || l.starts_with("en-")
43                    || l == "eng" =>
44            {
45                true
46            }
47            _ => false,
48        }
49    }
50}
51
52#[async_trait]
53impl TextCleanup for RulesCleanup {
54    fn name(&self) -> &'static str {
55        "rules"
56    }
57
58    fn kind(&self) -> CleanupProviderKind {
59        CleanupProviderKind::Rules
60    }
61
62    async fn cleanup(&self, text: &str, style: CleanupStyle) -> Result<CleanupResult> {
63        let original = text.to_string();
64        let english = self.is_english();
65        let body = if text.trim().is_empty() {
66            String::new()
67        } else {
68            match style {
69                CleanupStyle::Raw => text.trim().to_string(),
70                CleanupStyle::Clean => {
71                    if english {
72                        clean_english(text)
73                    } else {
74                        whitespace_safe(text)
75                    }
76                }
77                CleanupStyle::Bullets => {
78                    let base = if english {
79                        clean_english(text)
80                    } else {
81                        whitespace_safe(text)
82                    };
83                    to_bullets(&base)
84                }
85                CleanupStyle::Professional => {
86                    if english {
87                        professional_english(&clean_english(text))
88                    } else {
89                        whitespace_safe(text)
90                    }
91                }
92                CleanupStyle::Summary => {
93                    let base = if english {
94                        clean_english(text)
95                    } else {
96                        whitespace_safe(text)
97                    };
98                    summarize(&base)
99                }
100            }
101        };
102        Ok(CleanupResult {
103            text: body,
104            style,
105            provider: CleanupProviderKind::Rules,
106            original_text: original,
107        })
108    }
109}
110
111// --- Precompiled patterns (no per-call Regex::new for hot paths) ---
112
113static RE_WS2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]{2,}").expect("regex"));
114static RE_SPACE_PUNCT: LazyLock<Regex> =
115    LazyLock::new(|| Regex::new(r"[ \t]+([,.!?;:])").expect("regex"));
116static RE_SENT_SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[.!?]+\s+").expect("regex"));
117
118// English fillers (precompiled).
119static RE_FILLER_MULTI: LazyLock<Vec<Regex>> = LazyLock::new(|| {
120    [
121        "you know", "i mean", "sort of", "kind of", "okay so", "so yeah",
122    ]
123    .into_iter()
124    .map(|f| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(f))).expect("filler"))
125    .collect()
126});
127static RE_FILLER_SINGLE: LazyLock<Vec<Regex>> = LazyLock::new(|| {
128    ["um", "uh", "uhm", "erm", "ah", "eh"]
129        .into_iter()
130        .map(|f| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(f))).expect("filler"))
131        .collect()
132});
133
134// Safe professional expansions only (no ambiguous I'd / it's).
135static RE_PRO_SAFE: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
136    [
137        (r"(?i)\bcan't\b", "cannot"),
138        (r"(?i)\bwon't\b", "will not"),
139        (r"(?i)\bdon't\b", "do not"),
140        (r"(?i)\bdidn't\b", "did not"),
141        (r"(?i)\bisn't\b", "is not"),
142        (r"(?i)\baren't\b", "are not"),
143        (r"(?i)\bwasn't\b", "was not"),
144        (r"(?i)\bweren't\b", "were not"),
145        (r"(?i)\bhaven't\b", "have not"),
146        (r"(?i)\bhasn't\b", "has not"),
147        (r"(?i)\bhadn't\b", "had not"),
148        (r"(?i)\bI'm\b", "I am"),
149        (r"(?i)\bI've\b", "I have"),
150        (r"(?i)\bI'll\b", "I will"),
151        // Intentionally omitted: I'd, it's, that's, there's (ambiguous).
152        (r"(?i)\bwe're\b", "we are"),
153        (r"(?i)\bwe've\b", "we have"),
154        (r"(?i)\bwe'll\b", "we will"),
155        (r"(?i)\bthey're\b", "they are"),
156        (r"(?i)\bthey've\b", "they have"),
157        (r"(?i)\bgonna\b", "going to"),
158        (r"(?i)\bwanna\b", "want to"),
159        (r"(?i)\bkinda\b", "kind of"),
160        (r"(?i)\byeah\b", "yes"),
161        (r"(?i)\byep\b", "yes"),
162        (r"(?i)\bnope\b", "no"),
163        (r"(?i)\basap\b", "as soon as possible"),
164        (r"(?i)\bfyi\b", "for your information"),
165    ]
166    .into_iter()
167    .map(|(p, r)| (Regex::new(p).expect("pro"), r))
168    .collect()
169});
170
171/// Whitespace-safe normalization preserving paragraph breaks.
172fn whitespace_safe(text: &str) -> String {
173    let paragraphs: Vec<String> = text
174        .split("\n\n")
175        .map(|p| {
176            let line = p
177                .lines()
178                .map(|l| l.trim())
179                .filter(|l| !l.is_empty())
180                .collect::<Vec<_>>()
181                .join(" ");
182            RE_WS2.replace_all(&line, " ").into_owned()
183        })
184        .filter(|p| !p.trim().is_empty())
185        .collect();
186    paragraphs.join("\n\n")
187}
188
189fn clean_english(text: &str) -> String {
190    // Preserve paragraphs: clean each block independently.
191    let paragraphs: Vec<String> = text
192        .split("\n\n")
193        .map(clean_english_paragraph)
194        .filter(|p| !p.is_empty())
195        .collect();
196    paragraphs.join("\n\n")
197}
198
199fn clean_english_paragraph(text: &str) -> String {
200    // Collapse horizontal whitespace and single newlines inside a paragraph.
201    let mut result = text
202        .lines()
203        .map(|l| l.trim())
204        .filter(|l| !l.is_empty())
205        .collect::<Vec<_>>()
206        .join(" ");
207    result = RE_WS2.replace_all(&result, " ").into_owned();
208
209    for re in RE_FILLER_MULTI.iter() {
210        result = re.replace_all(&result, "").into_owned();
211    }
212    for re in RE_FILLER_SINGLE.iter() {
213        result = re.replace_all(&result, "").into_owned();
214    }
215
216    result = RE_WS2.replace_all(&result, " ").into_owned();
217    result = RE_SPACE_PUNCT.replace_all(&result, "$1").into_owned();
218    result = capitalize_sentences(result.trim());
219
220    if result.chars().count() > 12 {
221        if let Some(last) = result.chars().last() {
222            if !".!?".contains(last) {
223                result.push('.');
224            }
225        }
226    }
227    result
228}
229
230fn professional_english(text: &str) -> String {
231    let paragraphs: Vec<String> = text
232        .split("\n\n")
233        .map(|p| {
234            let mut result = p.to_string();
235            for (re, rep) in RE_PRO_SAFE.iter() {
236                result = re.replace_all(&result, *rep).into_owned();
237            }
238            result = RE_WS2.replace_all(&result, " ").into_owned();
239            capitalize_sentences(result.trim())
240        })
241        .filter(|p| !p.is_empty())
242        .collect();
243    paragraphs.join("\n\n")
244}
245
246fn to_bullets(text: &str) -> String {
247    let mut parts = split_sentences(text);
248    if parts.len() == 1 {
249        let commas: Vec<_> = text
250            .split(',')
251            .map(|s| s.trim())
252            .filter(|s| !s.is_empty())
253            .map(|s| s.to_string())
254            .collect();
255        if commas.len() >= 3 {
256            parts = commas;
257        }
258    }
259    if parts.len() <= 1 {
260        return format!("• {text}");
261    }
262    parts
263        .into_iter()
264        .map(|p| p.trim_matches(|c: char| ".!?;: ".contains(c)).to_string())
265        .filter(|p| !p.is_empty())
266        .map(|p| format!("• {}", capitalize_first(&p)))
267        .collect::<Vec<_>>()
268        .join("\n")
269}
270
271fn summarize(text: &str) -> String {
272    let sentences = split_sentences(text);
273    if sentences.len() <= 2 {
274        return text.to_string();
275    }
276    let first = sentences[0].clone();
277    let rest = &sentences[1..];
278    let longest = rest
279        .iter()
280        .max_by_key(|s| s.chars().count())
281        .cloned()
282        .unwrap_or_default();
283    if longest.is_empty() || longest == first {
284        first
285    } else {
286        format!("{first} {longest}")
287    }
288}
289
290fn split_sentences(text: &str) -> Vec<String> {
291    let mut out: Vec<String> = RE_SENT_SPLIT
292        .split(text)
293        .map(|s| s.trim().to_string())
294        .filter(|s| !s.is_empty())
295        .collect();
296    if out.is_empty() {
297        out.push(text.to_string());
298    }
299    out
300}
301
302fn capitalize_sentences(text: &str) -> String {
303    let mut result = String::with_capacity(text.len());
304    let mut capitalize_next = true;
305    for ch in text.chars() {
306        if capitalize_next && ch.is_alphabetic() {
307            for c in ch.to_uppercase() {
308                result.push(c);
309            }
310            capitalize_next = false;
311        } else {
312            result.push(ch);
313            if ".!?".contains(ch) {
314                capitalize_next = true;
315            }
316        }
317    }
318    result
319}
320
321fn capitalize_first(text: &str) -> String {
322    let mut chars = text.chars();
323    match chars.next() {
324        None => String::new(),
325        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[tokio::test]
334    async fn english_clean_strips_um() {
335        let r = RulesCleanup::new()
336            .with_language("en")
337            .cleanup("um, hello there", CleanupStyle::Clean)
338            .await
339            .unwrap();
340        assert!(r.text.to_ascii_lowercase().contains("hello"));
341        assert!(!r.text.to_ascii_lowercase().contains("um"));
342    }
343
344    #[tokio::test]
345    async fn french_does_not_strip_english_fillers_as_english() {
346        // "um" as substring of French should not be English-filler processed.
347        // We only apply whitespace-safe path — content preserved aside from trim.
348        let r = RulesCleanup::new()
349            .with_language("fr")
350            .cleanup("Bonjour, um, le monde", CleanupStyle::Clean)
351            .await
352            .unwrap();
353        // Whitespace-safe keeps words; may keep "um" as a token.
354        assert!(r.text.contains("Bonjour") || r.text.to_ascii_lowercase().contains("bonjour"));
355        assert!(r.text.contains("monde"));
356    }
357
358    #[tokio::test]
359    async fn professional_preserves_id_contraction() {
360        let r = RulesCleanup::new()
361            .with_language("en")
362            .cleanup("I'd like that", CleanupStyle::Professional)
363            .await
364            .unwrap();
365        // Must not force "I would" (could have been "I had").
366        assert!(
367            r.text.contains("I'd") || r.text.contains("I d"),
368            "got: {}",
369            r.text
370        );
371        assert!(!r.text.contains("I would"));
372    }
373
374    #[tokio::test]
375    async fn paragraphs_preserved_in_clean() {
376        let input = "First paragraph um here.\n\nSecond paragraph uh there.";
377        let r = RulesCleanup::new()
378            .with_language("en")
379            .cleanup(input, CleanupStyle::Clean)
380            .await
381            .unwrap();
382        assert!(
383            r.text.contains("\n\n"),
384            "paragraph break lost: {:?}",
385            r.text
386        );
387    }
388}