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#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct CleanupReport {
171 pub style: CleanupStyle,
172 pub provider: CleanupProviderKind,
173 pub segment_policy: SegmentCleanupPolicy,
174 pub changed_fields: Vec<String>,
176 pub warnings: Vec<String>,
177 pub dropped_segments: usize,
179 pub segments_cleared: bool,
181}
182
183pub const MAX_PER_SEGMENT_COUNT: usize = 2_000;
186pub const MAX_PER_SEGMENT_CHARS: usize = 8_000;
187
188#[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
197pub 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
210pub 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 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 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 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 if !matches!(style, CleanupStyle::Clean | CleanupStyle::Professional) {
286 }
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 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 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
362pub 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 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 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}