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 over bare text.
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/// Structured report for a full-result cleanup transaction.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct CleanupReport {
171    pub style: CleanupStyle,
172    pub provider: CleanupProviderKind,
173    pub segment_policy: SegmentCleanupPolicy,
174    /// Fields that changed relative to the input result.
175    pub changed_fields: Vec<String>,
176    pub warnings: Vec<String>,
177    /// Segments dropped because rewritten text was empty (per-segment policy).
178    pub dropped_segments: usize,
179    /// Whether timings were cleared under the applied policy.
180    pub segments_cleared: bool,
181}
182
183/// Hard bounds for per-segment cleanup (failure semantics live here; remote
184/// batching belongs to a follow-up issue).
185pub const MAX_PER_SEGMENT_COUNT: usize = 2_000;
186pub const MAX_PER_SEGMENT_CHARS: usize = 8_000;
187
188/// Pluggable cleanup backend.
189#[async_trait]
190pub trait TextCleanup: Send + Sync {
191    fn name(&self) -> &'static str;
192    fn kind(&self) -> CleanupProviderKind;
193
194    async fn cleanup(&self, text: &str, style: CleanupStyle) -> Result<CleanupResult>;
195}
196
197/// Apply cleanup to a full transcription result.
198///
199/// Updates `text` and cleanup metadata. Segment handling follows `segments` policy.
200pub async fn apply_cleanup(
201    result: &mut TranscriptionResult,
202    cleanup: &dyn TextCleanup,
203    style: CleanupStyle,
204) -> Result<CleanupResult> {
205    let (out, _report) =
206        apply_cleanup_with_segments(result, cleanup, style, SegmentCleanupPolicy::Auto).await?;
207    Ok(out)
208}
209
210/// Like [`apply_cleanup`] with explicit segment policy.
211///
212/// **Transactional:** the caller's `TranscriptionResult` is mutated only after
213/// the full operation validates. An injected failure on segment N leaves the
214/// original result byte-for-byte unchanged.
215pub async fn apply_cleanup_with_segments(
216    result: &mut TranscriptionResult,
217    cleanup: &dyn TextCleanup,
218    style: CleanupStyle,
219    segments: SegmentCleanupPolicy,
220) -> Result<(CleanupResult, CleanupReport)> {
221    let policy = segments.resolve(style);
222
223    if matches!(style, CleanupStyle::Raw) {
224        // Raw remains the identity default — no metadata pollution.
225        result.cleanup_style = CleanupStyle::Raw;
226        result.cleanup_provider = None;
227        result.original_text = None;
228        result.original_segments = None;
229        result.cleanup_segment_policy = None;
230        let out = CleanupResult {
231            text: result.text.clone(),
232            style,
233            provider: cleanup.kind(),
234            original_text: result.text.clone(),
235        };
236        let report = CleanupReport {
237            style,
238            provider: cleanup.kind(),
239            segment_policy: policy,
240            changed_fields: vec![],
241            warnings: vec![],
242            dropped_segments: 0,
243            segments_cleared: false,
244        };
245        return Ok((out, report));
246    }
247
248    // Bound per-segment work before any mutation.
249    if matches!(policy, SegmentCleanupPolicy::PerSegment) {
250        if result.segments.len() > MAX_PER_SEGMENT_COUNT {
251            return Err(UserError::Other {
252                message: format!(
253                    "per-segment cleanup refused: {} segments exceeds limit of {MAX_PER_SEGMENT_COUNT}",
254                    result.segments.len()
255                ),
256            }
257            .into());
258        }
259        for (i, seg) in result.segments.iter().enumerate() {
260            let n = seg.text.chars().count();
261            if n > MAX_PER_SEGMENT_CHARS {
262                return Err(UserError::Other {
263                    message: format!(
264                        "per-segment cleanup refused: segment {i} has {n} chars \
265                         (limit {MAX_PER_SEGMENT_CHARS})"
266                    ),
267                }
268                .into());
269            }
270        }
271    }
272
273    // Snapshot inputs; mutate only after full success.
274    let original_text = result.text.clone();
275    let original_segments = result.segments.clone();
276    let mut warnings = Vec::new();
277    let mut dropped_segments = 0usize;
278    let mut segments_cleared = false;
279
280    let out = cleanup.cleanup(&original_text, style).await?;
281
282    let proposed_segments = match policy {
283        SegmentCleanupPolicy::Keep | SegmentCleanupPolicy::Auto => {
284            // Keep timings; TXT may diverge from SRT segment text for light styles.
285            if !matches!(style, CleanupStyle::Clean | CleanupStyle::Professional) {
286                // Should not happen for Keep defaults, but record honesty.
287            }
288            if matches!(style, CleanupStyle::Clean | CleanupStyle::Professional) {
289                warnings.push(
290                    "segment timings kept; segment text is pre-cleanup ASR while \
291                     `text` is cleaned (JSON exposes original_text)"
292                        .into(),
293                );
294            }
295            original_segments.clone()
296        }
297        SegmentCleanupPolicy::Clear => {
298            segments_cleared = true;
299            warnings.push(
300                "segments cleared under structural/explicit clear policy; \
301                 use original_segments for raw ASR timings"
302                    .into(),
303            );
304            Vec::new()
305        }
306        SegmentCleanupPolicy::PerSegment => {
307            let mut cleaned = Vec::with_capacity(original_segments.len());
308            for (i, seg) in original_segments.iter().enumerate() {
309                let piece = match cleanup.cleanup(&seg.text, style).await {
310                    Ok(p) => p,
311                    Err(e) => {
312                        // No mutation yet — original `result` is intact.
313                        return Err(UserError::Other {
314                            message: format!("per-segment cleanup failed on segment {i}: {e}"),
315                        }
316                        .into());
317                    }
318                };
319                let mut seg = seg.clone();
320                seg.text = piece.text;
321                if seg.text.trim().is_empty() {
322                    dropped_segments += 1;
323                } else {
324                    cleaned.push(seg);
325                }
326            }
327            cleaned
328        }
329    };
330
331    // Commit proposed state only now.
332    let mut changed_fields = vec![
333        "text".into(),
334        "cleanup_style".into(),
335        "cleanup_provider".into(),
336    ];
337    result.original_text = Some(original_text.clone());
338    result.original_segments = Some(original_segments);
339    result.text = out.text.clone();
340    result.cleanup_style = out.style;
341    result.cleanup_provider = Some(out.provider);
342    result.cleanup_segment_policy = Some(policy);
343    if result.segments != proposed_segments {
344        changed_fields.push("segments".into());
345    }
346    result.segments = proposed_segments;
347    changed_fields.push("original_text".into());
348    changed_fields.push("original_segments".into());
349
350    let report = CleanupReport {
351        style: out.style,
352        provider: out.provider,
353        segment_policy: policy,
354        changed_fields,
355        warnings,
356        dropped_segments,
357        segments_cleared,
358    };
359    Ok((out, report))
360}
361
362/// Clean a bare string (stdin / text file path) without a full transcription result.
363pub async fn cleanup_text(
364    text: &str,
365    cleanup: &dyn TextCleanup,
366    style: CleanupStyle,
367) -> Result<CleanupResult> {
368    if matches!(style, CleanupStyle::Raw) {
369        return Ok(CleanupResult {
370            text: text.trim().to_string(),
371            style,
372            provider: cleanup.kind(),
373            original_text: text.to_string(),
374        });
375    }
376    cleanup.cleanup(text, style).await
377}
378
379pub use openrouter::OpenRouterCleanup;
380pub use rules::RulesCleanup;
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::providers::Segment;
386
387    #[tokio::test]
388    async fn rules_clean_strips_fillers() {
389        let c = RulesCleanup::new();
390        let out = c
391            .cleanup(
392                "um, hello there, you know, this is a test",
393                CleanupStyle::Clean,
394            )
395            .await
396            .unwrap();
397        let lower = out.text.to_ascii_lowercase();
398        assert!(!lower.contains(" um"));
399        assert!(lower.contains("hello"));
400        assert!(lower.contains("test"));
401    }
402
403    #[tokio::test]
404    async fn rules_bullets() {
405        let c = RulesCleanup::new();
406        let out = c
407            .cleanup(
408                "First point here. Second point there. Third idea now.",
409                CleanupStyle::Bullets,
410            )
411            .await
412            .unwrap();
413        assert!(out.text.contains('•'));
414        assert!(out.text.lines().count() >= 2);
415    }
416
417    #[tokio::test]
418    async fn raw_trims_only() {
419        let c = RulesCleanup::new();
420        let out = c.cleanup(" keep this ", CleanupStyle::Raw).await.unwrap();
421        assert_eq!(out.text, "keep this");
422    }
423
424    #[tokio::test]
425    async fn structural_auto_clears_segments() {
426        let c = RulesCleanup::new();
427        let mut result = TranscriptionResult::local(
428            "One. Two. Three.".into(),
429            vec![
430                Segment {
431                    start: 0.0,
432                    end: 1.0,
433                    text: "One.".into(),
434                },
435                Segment {
436                    start: 1.0,
437                    end: 2.0,
438                    text: "Two.".into(),
439                },
440            ],
441            Some("en".into()),
442            "tiny".into(),
443            2.0,
444        );
445        let (_out, report) = apply_cleanup_with_segments(
446            &mut result,
447            &c,
448            CleanupStyle::Bullets,
449            SegmentCleanupPolicy::Auto,
450        )
451        .await
452        .unwrap();
453        assert!(result.segments.is_empty());
454        assert!(result.text.contains('•'));
455        assert_eq!(result.cleanup_style, CleanupStyle::Bullets);
456        assert_eq!(result.cleanup_provider, Some(CleanupProviderKind::Rules));
457        assert!(report.segments_cleared);
458        assert!(result.original_segments.as_ref().unwrap().len() == 2);
459        assert_eq!(result.original_text.as_deref(), Some("One. Two. Three."));
460    }
461
462    #[tokio::test]
463    async fn keep_preserves_segments() {
464        let c = RulesCleanup::new();
465        let mut result = TranscriptionResult::local(
466            "um hello there".into(),
467            vec![Segment {
468                start: 0.0,
469                end: 1.0,
470                text: "um hello there".into(),
471            }],
472            None,
473            "tiny".into(),
474            1.0,
475        );
476        apply_cleanup_with_segments(
477            &mut result,
478            &c,
479            CleanupStyle::Clean,
480            SegmentCleanupPolicy::Keep,
481        )
482        .await
483        .unwrap();
484        assert_eq!(result.segments.len(), 1);
485        assert_eq!(result.segments[0].text, "um hello there");
486        assert!(result.original_text.is_some());
487    }
488
489    /// Failing backend used only for transactional guarantees.
490    struct FailAfterN {
491        n: std::sync::atomic::AtomicUsize,
492        fail_at: usize,
493        inner: RulesCleanup,
494    }
495
496    #[async_trait]
497    impl TextCleanup for FailAfterN {
498        fn name(&self) -> &'static str {
499            "fail-after-n"
500        }
501        fn kind(&self) -> CleanupProviderKind {
502            CleanupProviderKind::Rules
503        }
504        async fn cleanup(&self, text: &str, style: CleanupStyle) -> Result<CleanupResult> {
505            let i = self.n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
506            if i >= self.fail_at {
507                return Err(UserError::Other {
508                    message: format!("injected failure at call {i}"),
509                }
510                .into());
511            }
512            self.inner.cleanup(text, style).await
513        }
514    }
515
516    #[tokio::test]
517    async fn per_segment_failure_leaves_result_unchanged() {
518        let backend = FailAfterN {
519            n: std::sync::atomic::AtomicUsize::new(0),
520            // Full-transcript cleanup succeeds (call 0); first segment rewrite fails (call 1).
521            fail_at: 1,
522            inner: RulesCleanup::new(),
523        };
524        let mut result = TranscriptionResult::local(
525            "um one. um two.".into(),
526            vec![
527                Segment {
528                    start: 0.0,
529                    end: 1.0,
530                    text: "um one.".into(),
531                },
532                Segment {
533                    start: 1.0,
534                    end: 2.0,
535                    text: "um two.".into(),
536                },
537            ],
538            None,
539            "tiny".into(),
540            2.0,
541        );
542        let before = serde_json::to_string(&result).unwrap();
543        let err = apply_cleanup_with_segments(
544            &mut result,
545            &backend,
546            CleanupStyle::Clean,
547            SegmentCleanupPolicy::PerSegment,
548        )
549        .await
550        .unwrap_err();
551        assert!(err.to_string().contains("segment"), "{err}");
552        let after = serde_json::to_string(&result).unwrap();
553        assert_eq!(before, after, "result must be unchanged after failure");
554    }
555
556    #[tokio::test]
557    async fn cleanup_text_standalone() {
558        let c = RulesCleanup::new();
559        let out = cleanup_text("um, hi there", &c, CleanupStyle::Clean)
560            .await
561            .unwrap();
562        assert!(!out.text.to_ascii_lowercase().contains("um"));
563    }
564}