Skip to main content

aurum_core/
postprocess.rs

1//! Shared transcript cleanup applied after every provider (JOE-1609).
2//!
3//! Whisper (and some remote models) emit control / non-speech tokens that should
4//! not leak into scripted `txt`/`srt`/`json` output. Normalization produces an
5//! explicit [`NormalizationReport`] listing repairs and drops.
6
7use crate::providers::{BackendKind, Segment, TranscriptionResult};
8use serde::{Deserialize, Serialize};
9
10/// Known non-speech / control markers frequently emitted by whisper.cpp.
11const SPECIAL_MARKERS: &[&str] = &[
12    "[BLANK_AUDIO]",
13    "[blank_audio]",
14    "[MUSIC]",
15    "[Music]",
16    "[music]",
17    "[SILENCE]",
18    "[Silence]",
19    "[silence]",
20    "[NOISE]",
21    "[Noise]",
22    "[noise]",
23    "[INAUDIBLE]",
24    "[Inaudible]",
25    "[CLICK]",
26    "[Click]",
27    "[APPLAUSE]",
28    "[Applause]",
29    "[LAUGHTER]",
30    "[Laughter]",
31    "[COUGH]",
32    "[Cough]",
33    "♪",
34    "♫",
35];
36
37/// One normalization repair or warning (deterministic, no provider payloads).
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct NormalizationEvent {
40    pub code: String,
41    pub detail: String,
42}
43
44/// Report of repairs applied while normalizing a provider result.
45#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub struct NormalizationReport {
47    pub events: Vec<NormalizationEvent>,
48    pub dropped_segments: usize,
49    pub repaired_timestamps: usize,
50    pub markers_stripped: bool,
51}
52
53impl NormalizationReport {
54    pub fn warnings(&self) -> Vec<String> {
55        self.events
56            .iter()
57            .map(|e| format!("{}: {}", e.code, e.detail))
58            .collect()
59    }
60
61    pub fn is_clean(&self) -> bool {
62        self.events.is_empty()
63    }
64}
65
66/// Clean provider output into a stable, scriptable result.
67pub fn normalize_result(result: TranscriptionResult) -> TranscriptionResult {
68    normalize_result_with_report(result).0
69}
70
71/// Conservative transcript degeneration detector (JOE-1650).
72///
73/// Flags unbounded n-gram loops (e.g. whisper hallucination of a short phrase
74/// repeated dozens of times). Thresholds are intentionally high to avoid
75/// flagging lyrics, stutters, or legitimate short repetitions.
76///
77/// Returns `Some(detail)` when the transcript looks degenerate.
78pub fn detect_repetition_degeneration(text: &str) -> Option<String> {
79    let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect();
80    if words.len() < 40 {
81        return None;
82    }
83    // Count max run of identical 5-grams.
84    const N: usize = 5;
85    const MAX_SAME_NGRAM: usize = 12; // ~60 words of pure loop
86    if words.len() < N + MAX_SAME_NGRAM {
87        return None;
88    }
89    let mut best = 0usize;
90    let mut best_phrase = String::new();
91    let mut i = 0usize;
92    while i + N <= words.len() {
93        let phrase = &words[i..i + N];
94        let mut count = 1usize;
95        let mut j = i + N;
96        while j + N <= words.len() && &words[j..j + N] == phrase {
97            count += 1;
98            j += N;
99        }
100        if count > best {
101            best = count;
102            best_phrase = phrase.join(" ");
103        }
104        if count >= MAX_SAME_NGRAM {
105            return Some(format!(
106                "repeated 5-gram {count}× (\"{best_phrase}\") — possible model degeneration"
107            ));
108        }
109        i += 1;
110    }
111    // Also flag when a single 5-gram occupies >40% of the transcript via non-adjacent counts.
112    use std::collections::HashMap;
113    let mut freq: HashMap<String, usize> = HashMap::new();
114    for w in words.windows(N) {
115        *freq.entry(w.join(" ")).or_default() += 1;
116    }
117    if let Some((phrase, c)) = freq.into_iter().max_by_key(|(_, c)| *c) {
118        if c >= MAX_SAME_NGRAM && (c * N * 100 / words.len()) >= 40 {
119            return Some(format!(
120                "5-gram occupies large fraction of transcript ({c}× \"{phrase}\") — possible model degeneration"
121            ));
122        }
123    }
124    let _ = best_phrase;
125    let _ = best;
126    None
127}
128
129/// Like [`normalize_result`] but returns an explicit repair report.
130pub fn normalize_result_with_report(
131    mut result: TranscriptionResult,
132) -> (TranscriptionResult, NormalizationReport) {
133    let mut report = NormalizationReport::default();
134
135    if let Some(detail) = detect_repetition_degeneration(&result.text) {
136        report.events.push(NormalizationEvent {
137            code: "degeneration_repetition".into(),
138            detail,
139        });
140    }
141
142    // Backend / reliability consistency.
143    if matches!(result.backend_kind, BackendKind::LlmAssisted) && result.timestamps_reliable {
144        result.timestamps_reliable = false;
145        report.events.push(NormalizationEvent {
146            code: "backend_reliability".into(),
147            detail: "LLM-assisted backend cannot claim reliable timestamps".into(),
148        });
149    }
150
151    if !result.duration_secs.is_finite() || result.duration_secs < 0.0 {
152        report.events.push(NormalizationEvent {
153            code: "duration".into(),
154            detail: format!("non-finite or negative duration {}", result.duration_secs),
155        });
156        result.duration_secs = 0.0;
157    }
158
159    let mut cleaned_segments = Vec::with_capacity(result.segments.len());
160    for mut seg in result.segments.drain(..) {
161        let before = seg.text.clone();
162        seg.text = strip_markers(&seg.text);
163        if seg.text != before {
164            report.markers_stripped = true;
165        }
166        let trimmed = seg.text.trim();
167        if trimmed.is_empty() || is_only_marker(trimmed) {
168            report.dropped_segments += 1;
169            report.events.push(NormalizationEvent {
170                code: "drop_segment".into(),
171                detail: "empty or marker-only segment".into(),
172            });
173            continue;
174        }
175        seg.text = trimmed.to_string();
176
177        if !seg.start.is_finite() || !seg.end.is_finite() {
178            report.dropped_segments += 1;
179            report.events.push(NormalizationEvent {
180                code: "drop_segment".into(),
181                detail: "non-finite timestamps".into(),
182            });
183            continue;
184        }
185
186        let mut repaired = false;
187        if result.duration_secs > 0.0 {
188            let ns = seg.start.clamp(0.0, result.duration_secs);
189            let ne = seg.end.clamp(0.0, result.duration_secs);
190            if ns != seg.start || ne != seg.end {
191                repaired = true;
192            }
193            seg.start = ns;
194            seg.end = ne;
195        } else {
196            if seg.start < 0.0 {
197                seg.start = 0.0;
198                repaired = true;
199            }
200            if seg.end < 0.0 {
201                seg.end = 0.0;
202                repaired = true;
203            }
204        }
205        if seg.end < seg.start {
206            std::mem::swap(&mut seg.start, &mut seg.end);
207            repaired = true;
208            report.events.push(NormalizationEvent {
209                code: "swap_timestamps".into(),
210                detail: "inverted segment span swapped".into(),
211            });
212        }
213        if repaired {
214            report.repaired_timestamps += 1;
215        }
216        cleaned_segments.push(seg);
217    }
218    result.segments = cleaned_segments;
219
220    if !result.segments.is_empty() {
221        result.text = join_segment_text(&result.segments);
222    } else {
223        let before = result.text.clone();
224        result.text = strip_markers(&result.text);
225        if result.text != before {
226            report.markers_stripped = true;
227        }
228        result.text = result.text.trim().to_string();
229        if is_only_marker(&result.text) {
230            result.text.clear();
231        }
232    }
233
234    (result, report)
235}
236
237fn join_segment_text(segments: &[Segment]) -> String {
238    let mut out = String::new();
239    for seg in segments {
240        let t = seg.text.trim();
241        if t.is_empty() {
242            continue;
243        }
244        if !out.is_empty() {
245            out.push(' ');
246        }
247        out.push_str(t);
248    }
249    out
250}
251
252fn strip_markers(text: &str) -> String {
253    let mut out = text.to_string();
254    for marker in SPECIAL_MARKERS {
255        out = out.replace(marker, " ");
256    }
257    out.split_whitespace().collect::<Vec<_>>().join(" ")
258}
259
260fn is_only_marker(text: &str) -> bool {
261    let t = text.trim();
262    if t.is_empty() {
263        return false;
264    }
265    SPECIAL_MARKERS
266        .iter()
267        .any(|m| t.eq_ignore_ascii_case(m.trim_matches(|c| c == '[' || c == ']')))
268        || SPECIAL_MARKERS.iter().any(|m| t.eq_ignore_ascii_case(m))
269}
270
271/// UTF-8–safe truncation for error messages (never panics on char boundaries).
272pub fn truncate_chars(s: &str, max_chars: usize) -> String {
273    if s.chars().count() <= max_chars {
274        return s.to_string();
275    }
276    let truncated: String = s.chars().take(max_chars).collect();
277    format!("{truncated}…")
278}
279
280/// Validated segment constructor (rejects NaN/inverted spans).
281pub fn validated_segment(start: f64, end: f64, text: impl Into<String>) -> Option<Segment> {
282    if !start.is_finite() || !end.is_finite() || end < start {
283        return None;
284    }
285    let text = text.into();
286    if text.trim().is_empty() {
287        return None;
288    }
289    Some(Segment {
290        start,
291        end,
292        text: text.trim().to_string(),
293    })
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn detects_extreme_ngram_loop() {
302        let phrase = "feature and i think it's ";
303        let text = phrase.repeat(40);
304        let detail = detect_repetition_degeneration(&text).expect("should flag loop");
305        assert!(detail.contains("repeated") || detail.contains("fraction"));
306    }
307
308    #[test]
309    fn ordinary_text_not_flagged() {
310        let text = "There's something that a lot of people do when they're working with AI \
311                    to write code that totally drives me crazy and it's sort of something \
312                    that I've been railing against for a while now. The thing that I've \
313                    noticed is people tend to think I need to create a spec for AI.";
314        assert!(detect_repetition_degeneration(text).is_none());
315    }
316
317    #[test]
318    fn short_legitimate_repetition_ok() {
319        let text = "no no no I said yes yes yes please please please thank you thank you";
320        assert!(detect_repetition_degeneration(text).is_none());
321    }
322
323    #[test]
324    fn drops_nan_segments() {
325        let r = TranscriptionResult::local(
326            "x".into(),
327            vec![
328                Segment {
329                    start: f64::NAN,
330                    end: 1.0,
331                    text: "bad".into(),
332                },
333                Segment {
334                    start: 0.0,
335                    end: 1.0,
336                    text: "good".into(),
337                },
338            ],
339            None,
340            "m".into(),
341            2.0,
342        );
343        let (out, report) = normalize_result_with_report(r);
344        assert_eq!(out.segments.len(), 1);
345        assert_eq!(out.segments[0].text, "good");
346        assert!(report.dropped_segments >= 1);
347    }
348
349    #[test]
350    fn llm_backend_clears_reliable_flag() {
351        let mut r =
352            TranscriptionResult::openrouter("hi".into(), vec![], None, "m".into(), 1.0, true);
353        r.timestamps_reliable = true;
354        let (out, report) = normalize_result_with_report(r);
355        assert!(!out.timestamps_reliable);
356        assert!(!report.is_clean());
357    }
358
359    #[test]
360    fn validated_segment_rejects_inverted() {
361        assert!(validated_segment(2.0, 1.0, "x").is_none());
362        assert!(validated_segment(0.0, 1.0, "x").is_some());
363    }
364}