Skip to main content

aurum_core/cleanup/
mod.rs

1//! Post-transcription text cleanup ( "flow").
2//!
3//! Two backends:
4//! - **rules** (default): pure on-device regex/heuristics — no network, no model
5//! - **openrouter**: LLM rewrite via chat completions (optional, explicit)
6//!
7//! Cleanup is **opt-in** at the CLI/library boundary so raw ASR stays available.
8
9pub mod openrouter;
10pub mod rules;
11
12use crate::error::{Result, UserError};
13use crate::providers::TranscriptionResult;
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16
17/// How aggressively / in what shape to clean transcript text.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "snake_case")]
20pub enum CleanupStyle {
21    /// No cleanup (identity).
22    #[default]
23    Raw,
24    /// Collapse whitespace, drop fillers, light punctuation.
25    Clean,
26    /// Clean + bullet list.
27    Bullets,
28    /// Clean + expand contractions / slightly more formal tone (rules) or LLM polish.
29    Professional,
30    /// Extractive (rules) or abstractive (LLM) short summary.
31    Summary,
32}
33
34impl CleanupStyle {
35    pub fn parse(s: &str) -> Result<Self> {
36        match s.trim().to_ascii_lowercase().as_str() {
37            "raw" | "none" | "off" => Ok(Self::Raw),
38            "clean" => Ok(Self::Clean),
39            "bullets" | "bullet" => Ok(Self::Bullets),
40            "professional" | "pro" => Ok(Self::Professional),
41            "summary" | "sum" => Ok(Self::Summary),
42            other => Err(UserError::Other {
43                message: format!(
44 "unknown cleanup style '{other}'\n Hint: use one of: raw, clean, bullets, professional, summary"
45 ),
46            }
47            .into()),
48        }
49    }
50
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::Raw => "raw",
54            Self::Clean => "clean",
55            Self::Bullets => "bullets",
56            Self::Professional => "professional",
57            Self::Summary => "summary",
58        }
59    }
60
61    /// Styles that rewrite structure so ASR segment timings no longer match text.
62    pub fn is_structural(self) -> bool {
63        matches!(self, Self::Bullets | Self::Summary)
64    }
65}
66
67/// Where cleanup runs.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum CleanupProviderKind {
71    /// On-device rules only (default).
72    #[default]
73    Rules,
74    /// OpenRouter chat completion.
75    OpenRouter,
76}
77
78impl CleanupProviderKind {
79    pub fn parse(s: &str) -> Result<Self> {
80        match s.trim().to_ascii_lowercase().as_str() {
81            "rules" | "local" | "on-device" | "ondevice" => Ok(Self::Rules),
82            "openrouter" | "remote" | "llm" => Ok(Self::OpenRouter),
83            other => Err(UserError::Other {
84                message: format!(
85                    "unknown cleanup provider '{other}'\n Hint: use one of: rules, openrouter"
86                ),
87            }
88            .into()),
89        }
90    }
91
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Self::Rules => "rules",
95            Self::OpenRouter => "openrouter",
96        }
97    }
98}
99
100/// How to treat ASR segments after cleaning the full transcript text.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
102#[serde(rename_all = "snake_case")]
103pub enum SegmentCleanupPolicy {
104    /// Leave segments unchanged (best for timed SRT with `clean` / `professional`).
105    Keep,
106    /// Drop all segments (default for structural styles: bullets / summary).
107    Clear,
108    /// Run the same cleanup style on each segment's text (keeps timings).
109    PerSegment,
110    /// Pick a sensible default from the style (see [`default_for_style`](Self::default_for_style)).
111    #[default]
112    Auto,
113}
114
115impl SegmentCleanupPolicy {
116    pub fn parse(s: &str) -> Result<Self> {
117        match s.trim().to_ascii_lowercase().as_str() {
118            "keep" | "none" => Ok(Self::Keep),
119            "clear" | "drop" | "empty" => Ok(Self::Clear),
120            "per-segment" | "per_segment" | "each" | "segments" => Ok(Self::PerSegment),
121            "auto" | "default" => Ok(Self::Auto),
122            other => Err(UserError::Other {
123                message: format!(
124 "unknown segment cleanup policy '{other}'\n Hint: use one of: auto, keep, clear, per-segment"
125 ),
126            }
127            .into()),
128        }
129    }
130
131    pub fn as_str(self) -> &'static str {
132        match self {
133            Self::Keep => "keep",
134            Self::Clear => "clear",
135            Self::PerSegment => "per-segment",
136            Self::Auto => "auto",
137        }
138    }
139
140    /// Resolve `Auto` against a cleanup style.
141    pub fn resolve(self, style: CleanupStyle) -> Self {
142        match self {
143            Self::Auto => Self::default_for_style(style),
144            other => other,
145        }
146    }
147
148    /// Structural styles clear segments; light styles keep them.
149    pub fn default_for_style(style: CleanupStyle) -> Self {
150        if style.is_structural() {
151            Self::Clear
152        } else {
153            Self::Keep
154        }
155    }
156}
157
158/// Result of a cleanup pass.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct CleanupResult {
161    pub text: String,
162    pub style: CleanupStyle,
163    pub provider: CleanupProviderKind,
164    /// Original text before cleanup (for debugging / JSON).
165    pub original_text: String,
166}
167
168/// Pluggable cleanup backend.
169#[async_trait]
170pub trait TextCleanup: Send + Sync {
171    fn name(&self) -> &'static str;
172    fn kind(&self) -> CleanupProviderKind;
173
174    async fn cleanup(&self, text: &str, style: CleanupStyle) -> Result<CleanupResult>;
175}
176
177/// Apply cleanup to a full transcription result.
178///
179/// Updates `text` and cleanup metadata. Segment handling follows `segments` policy.
180pub async fn apply_cleanup(
181    result: &mut TranscriptionResult,
182    cleanup: &dyn TextCleanup,
183    style: CleanupStyle,
184) -> Result<CleanupResult> {
185    apply_cleanup_with_segments(result, cleanup, style, SegmentCleanupPolicy::Auto).await
186}
187
188/// Like [`apply_cleanup`] with explicit segment policy.
189pub async fn apply_cleanup_with_segments(
190    result: &mut TranscriptionResult,
191    cleanup: &dyn TextCleanup,
192    style: CleanupStyle,
193    segments: SegmentCleanupPolicy,
194) -> Result<CleanupResult> {
195    let policy = segments.resolve(style);
196
197    if matches!(style, CleanupStyle::Raw) {
198        result.cleanup_style = CleanupStyle::Raw;
199        result.cleanup_provider = None;
200        result.original_text = None;
201        return Ok(CleanupResult {
202            text: result.text.clone(),
203            style,
204            provider: cleanup.kind(),
205            original_text: result.text.clone(),
206        });
207    }
208
209    let out = cleanup.cleanup(&result.text, style).await?;
210    result.original_text = Some(out.original_text.clone());
211    result.text = out.text.clone();
212    result.cleanup_style = out.style;
213    result.cleanup_provider = Some(out.provider);
214
215    match policy {
216        SegmentCleanupPolicy::Keep | SegmentCleanupPolicy::Auto => {}
217        SegmentCleanupPolicy::Clear => {
218            result.segments.clear();
219        }
220        SegmentCleanupPolicy::PerSegment => {
221            // For structural styles, per-segment cleanup is usually wrong; still honor request.
222            let mut cleaned = Vec::with_capacity(result.segments.len());
223            for mut seg in result.segments.drain(..) {
224                let piece = cleanup.cleanup(&seg.text, style).await?;
225                seg.text = piece.text;
226                if !seg.text.trim().is_empty() {
227                    cleaned.push(seg);
228                }
229            }
230            result.segments = cleaned;
231        }
232    }
233
234    Ok(out)
235}
236
237/// Clean a bare string (stdin / text file path) without a full transcription result.
238pub async fn cleanup_text(
239    text: &str,
240    cleanup: &dyn TextCleanup,
241    style: CleanupStyle,
242) -> Result<CleanupResult> {
243    if matches!(style, CleanupStyle::Raw) {
244        return Ok(CleanupResult {
245            text: text.trim().to_string(),
246            style,
247            provider: cleanup.kind(),
248            original_text: text.to_string(),
249        });
250    }
251    cleanup.cleanup(text, style).await
252}
253
254pub use openrouter::OpenRouterCleanup;
255pub use rules::RulesCleanup;
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::providers::Segment;
261
262    #[tokio::test]
263    async fn rules_clean_strips_fillers() {
264        let c = RulesCleanup::new();
265        let out = c
266            .cleanup(
267                "um, hello there, you know, this is a test",
268                CleanupStyle::Clean,
269            )
270            .await
271            .unwrap();
272        let lower = out.text.to_ascii_lowercase();
273        assert!(!lower.contains(" um"));
274        assert!(lower.contains("hello"));
275        assert!(lower.contains("test"));
276    }
277
278    #[tokio::test]
279    async fn rules_bullets() {
280        let c = RulesCleanup::new();
281        let out = c
282            .cleanup(
283                "First point here. Second point there. Third idea now.",
284                CleanupStyle::Bullets,
285            )
286            .await
287            .unwrap();
288        assert!(out.text.contains('•'));
289        assert!(out.text.lines().count() >= 2);
290    }
291
292    #[tokio::test]
293    async fn raw_trims_only() {
294        let c = RulesCleanup::new();
295        let out = c.cleanup(" keep this ", CleanupStyle::Raw).await.unwrap();
296        assert_eq!(out.text, "keep this");
297    }
298
299    #[tokio::test]
300    async fn structural_auto_clears_segments() {
301        let c = RulesCleanup::new();
302        let mut result = TranscriptionResult::local(
303            "One. Two. Three.".into(),
304            vec![
305                Segment {
306                    start: 0.0,
307                    end: 1.0,
308                    text: "One.".into(),
309                },
310                Segment {
311                    start: 1.0,
312                    end: 2.0,
313                    text: "Two.".into(),
314                },
315            ],
316            Some("en".into()),
317            "tiny".into(),
318            2.0,
319        );
320        apply_cleanup_with_segments(
321            &mut result,
322            &c,
323            CleanupStyle::Bullets,
324            SegmentCleanupPolicy::Auto,
325        )
326        .await
327        .unwrap();
328        assert!(result.segments.is_empty());
329        assert!(result.text.contains('•'));
330        assert_eq!(result.cleanup_style, CleanupStyle::Bullets);
331        assert_eq!(result.cleanup_provider, Some(CleanupProviderKind::Rules));
332    }
333
334    #[tokio::test]
335    async fn keep_preserves_segments() {
336        let c = RulesCleanup::new();
337        let mut result = TranscriptionResult::local(
338            "um hello there".into(),
339            vec![Segment {
340                start: 0.0,
341                end: 1.0,
342                text: "um hello there".into(),
343            }],
344            None,
345            "tiny".into(),
346            1.0,
347        );
348        apply_cleanup_with_segments(
349            &mut result,
350            &c,
351            CleanupStyle::Clean,
352            SegmentCleanupPolicy::Keep,
353        )
354        .await
355        .unwrap();
356        assert_eq!(result.segments.len(), 1);
357    }
358
359    #[tokio::test]
360    async fn cleanup_text_standalone() {
361        let c = RulesCleanup::new();
362        let out = cleanup_text("um, hi there", &c, CleanupStyle::Clean)
363            .await
364            .unwrap();
365        assert!(!out.text.to_ascii_lowercase().contains("um"));
366    }
367}