Skip to main content

aurum_core/cleanup/
rules.rs

1//! On-device rule-based cleanup.
2//!
3//! No network, no ML weights — pure string/regex transforms.
4
5use super::{CleanupProviderKind, CleanupResult, CleanupStyle, TextCleanup};
6use crate::error::Result;
7use async_trait::async_trait;
8use regex::Regex;
9use std::sync::LazyLock;
10
11/// Local, deterministic text cleanup.
12#[derive(Debug, Default, Clone)]
13pub struct RulesCleanup;
14
15impl RulesCleanup {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21#[async_trait]
22impl TextCleanup for RulesCleanup {
23    fn name(&self) -> &'static str {
24        "rules"
25    }
26
27    fn kind(&self) -> CleanupProviderKind {
28        CleanupProviderKind::Rules
29    }
30
31    async fn cleanup(&self, text: &str, style: CleanupStyle) -> Result<CleanupResult> {
32        let original = text.to_string();
33        let trimmed = text.trim();
34        let body = if trimmed.is_empty() {
35            String::new()
36        } else {
37            match style {
38                CleanupStyle::Raw => trimmed.to_string(),
39                CleanupStyle::Clean => clean(trimmed),
40                CleanupStyle::Bullets => to_bullets(&clean(trimmed)),
41                CleanupStyle::Professional => professional(&clean(trimmed)),
42                CleanupStyle::Summary => summarize(&clean(trimmed)),
43            }
44        };
45        Ok(CleanupResult {
46            text: body,
47            style,
48            provider: CleanupProviderKind::Rules,
49            original_text: original,
50        })
51    }
52}
53
54static RE_WS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").expect("regex"));
55static RE_WS2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s{2,}").expect("regex"));
56static RE_SPACE_PUNCT: LazyLock<Regex> =
57    LazyLock::new(|| Regex::new(r"\s+([,.!?;:])").expect("regex"));
58static RE_SENT_SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[.!?]+\s+").expect("regex"));
59
60fn clean(text: &str) -> String {
61    let mut result = RE_WS.replace_all(text, " ").into_owned();
62
63    let multi = [
64        "you know", "i mean", "sort of", "kind of", "okay so", "so yeah",
65    ];
66    for filler in multi {
67        let pat = format!(r"(?i)\b{}\b[,.]?", regex::escape(filler));
68        result = Regex::new(&pat)
69            .expect("filler regex")
70            .replace_all(&result, "")
71            .into_owned();
72    }
73
74    let single = ["um", "uh", "uhm", "erm", "ah", "eh"];
75    for filler in single {
76        let pat = format!(r"(?i)\b{}\b[,.]?", regex::escape(filler));
77        result = Regex::new(&pat)
78            .expect("filler regex")
79            .replace_all(&result, "")
80            .into_owned();
81    }
82
83    result = RE_WS2.replace_all(&result, " ").into_owned();
84    result = RE_SPACE_PUNCT.replace_all(&result, "$1").into_owned();
85    result = capitalize_sentences(result.trim());
86
87    if result.chars().count() > 12 {
88        if let Some(last) = result.chars().last() {
89            if !".!?".contains(last) {
90                result.push('.');
91            }
92        }
93    }
94    result
95}
96
97fn to_bullets(text: &str) -> String {
98    let mut parts = split_sentences(text);
99    if parts.len() == 1 {
100        let commas: Vec<_> = text
101            .split(',')
102            .map(|s| s.trim())
103            .filter(|s| !s.is_empty())
104            .map(|s| s.to_string())
105            .collect();
106        if commas.len() >= 3 {
107            parts = commas;
108        }
109    }
110    if parts.len() <= 1 {
111        return format!("• {text}");
112    }
113    parts
114        .into_iter()
115        .map(|p| p.trim_matches(|c: char| ".!?;: ".contains(c)).to_string())
116        .filter(|p| !p.is_empty())
117        .map(|p| format!("• {}", capitalize_first(&p)))
118        .collect::<Vec<_>>()
119        .join("\n")
120}
121
122fn professional(text: &str) -> String {
123    let replacements: &[(&str, &str)] = &[
124        (r"(?i)\bcan't\b", "cannot"),
125        (r"(?i)\bwon't\b", "will not"),
126        (r"(?i)\bdon't\b", "do not"),
127        (r"(?i)\bdidn't\b", "did not"),
128        (r"(?i)\bisn't\b", "is not"),
129        (r"(?i)\baren't\b", "are not"),
130        (r"(?i)\bwasn't\b", "was not"),
131        (r"(?i)\bweren't\b", "were not"),
132        (r"(?i)\bhaven't\b", "have not"),
133        (r"(?i)\bhasn't\b", "has not"),
134        (r"(?i)\bhadn't\b", "had not"),
135        (r"(?i)\bI'm\b", "I am"),
136        (r"(?i)\bI've\b", "I have"),
137        (r"(?i)\bI'll\b", "I will"),
138        (r"(?i)\bI'd\b", "I would"),
139        (r"(?i)\bwe're\b", "we are"),
140        (r"(?i)\bwe've\b", "we have"),
141        (r"(?i)\bwe'll\b", "we will"),
142        (r"(?i)\bthey're\b", "they are"),
143        (r"(?i)\bthey've\b", "they have"),
144        (r"(?i)\bit's\b", "it is"),
145        (r"(?i)\bthat's\b", "that is"),
146        (r"(?i)\bthere's\b", "there is"),
147        (r"(?i)\bgonna\b", "going to"),
148        (r"(?i)\bwanna\b", "want to"),
149        (r"(?i)\bkinda\b", "kind of"),
150        (r"(?i)\byeah\b", "yes"),
151        (r"(?i)\byep\b", "yes"),
152        (r"(?i)\bnope\b", "no"),
153        (r"(?i)\bokay\b", "all right"),
154        (r"(?i)\bok\b", "all right"),
155        (r"(?i)\bhey\b", "hello"),
156        (r"(?i)\basap\b", "as soon as possible"),
157        (r"(?i)\bfyi\b", "for your information"),
158    ];
159
160    let mut result = text.to_string();
161    for (pat, rep) in replacements {
162        result = Regex::new(pat)
163            .expect("pro regex")
164            .replace_all(&result, *rep)
165            .into_owned();
166    }
167    result = RE_WS2.replace_all(&result, " ").into_owned();
168    capitalize_sentences(result.trim())
169}
170
171fn summarize(text: &str) -> String {
172    let sentences = split_sentences(text);
173    if sentences.len() <= 2 {
174        return text.to_string();
175    }
176    let first = sentences[0].clone();
177    let rest = &sentences[1..];
178    let longest = rest
179        .iter()
180        .max_by_key(|s| s.chars().count())
181        .cloned()
182        .unwrap_or_default();
183    if longest.is_empty() || longest == first {
184        first
185    } else {
186        format!("{first} {longest}")
187    }
188}
189
190fn split_sentences(text: &str) -> Vec<String> {
191    // Simple split on .!? followed by whitespace — good enough for v0.
192    let mut out: Vec<String> = RE_SENT_SPLIT
193        .split(text)
194        .map(|s| s.trim().to_string())
195        .filter(|s| !s.is_empty())
196        .collect();
197    if out.is_empty() {
198        out.push(text.to_string());
199    }
200    // Re-attach terminal punctuation roughly
201    out
202}
203
204fn capitalize_sentences(text: &str) -> String {
205    let mut result = String::with_capacity(text.len());
206    let mut capitalize_next = true;
207    for ch in text.chars() {
208        if capitalize_next && ch.is_alphabetic() {
209            for c in ch.to_uppercase() {
210                result.push(c);
211            }
212            capitalize_next = false;
213        } else {
214            result.push(ch);
215            if ".!?".contains(ch) {
216                capitalize_next = true;
217            }
218        }
219    }
220    result
221}
222
223fn capitalize_first(text: &str) -> String {
224    let mut chars = text.chars();
225    match chars.next() {
226        None => String::new(),
227        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
228    }
229}