1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "snake_case")]
20pub enum CleanupStyle {
21 #[default]
23 Raw,
24 Clean,
26 Bullets,
28 Professional,
30 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 pub fn is_structural(self) -> bool {
63 matches!(self, Self::Bullets | Self::Summary)
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum CleanupProviderKind {
71 #[default]
73 Rules,
74 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
102#[serde(rename_all = "snake_case")]
103pub enum SegmentCleanupPolicy {
104 Keep,
106 Clear,
108 PerSegment,
110 #[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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct CleanupResult {
161 pub text: String,
162 pub style: CleanupStyle,
163 pub provider: CleanupProviderKind,
164 pub original_text: String,
166}
167
168#[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
177pub 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
188pub 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 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
237pub 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}