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().to_string();
162        seg.set_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.set_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.set_start(ns);
194            seg.set_end(ne);
195        } else {
196            if seg.start() < 0.0 {
197                seg.set_start(0.0);
198                repaired = true;
199            }
200            if seg.end() < 0.0 {
201                seg.set_end(0.0);
202                repaired = true;
203            }
204        }
205        if seg.end() < seg.start() {
206            let (a, b) = (seg.start(), seg.end());
207            seg.set_start(b);
208            seg.set_end(a);
209            repaired = true;
210            report.events.push(NormalizationEvent {
211                code: "swap_timestamps".into(),
212                detail: "inverted segment span swapped".into(),
213            });
214        }
215        if repaired {
216            report.repaired_timestamps += 1;
217        }
218        cleaned_segments.push(seg);
219    }
220    result.segments = cleaned_segments;
221
222    if !result.segments.is_empty() {
223        result.text = join_segment_text(&result.segments);
224    } else {
225        let before = result.text.clone();
226        result.text = strip_markers(&result.text);
227        if result.text != before {
228            report.markers_stripped = true;
229        }
230        result.text = result.text.trim().to_string();
231        if is_only_marker(&result.text) {
232            result.text.clear();
233        }
234    }
235
236    (result, report)
237}
238
239fn join_segment_text(segments: &[Segment]) -> String {
240    let mut out = String::new();
241    for seg in segments {
242        let t = seg.text().trim();
243        if t.is_empty() {
244            continue;
245        }
246        if !out.is_empty() {
247            out.push(' ');
248        }
249        out.push_str(t);
250    }
251    out
252}
253
254fn strip_markers(text: &str) -> String {
255    let mut out = text.to_string();
256    for marker in SPECIAL_MARKERS {
257        out = out.replace(marker, " ");
258    }
259    out.split_whitespace().collect::<Vec<_>>().join(" ")
260}
261
262fn is_only_marker(text: &str) -> bool {
263    let t = text.trim();
264    if t.is_empty() {
265        return false;
266    }
267    SPECIAL_MARKERS
268        .iter()
269        .any(|m| t.eq_ignore_ascii_case(m.trim_matches(|c| c == '[' || c == ']')))
270        || SPECIAL_MARKERS.iter().any(|m| t.eq_ignore_ascii_case(m))
271}
272
273/// UTF-8–safe truncation for error messages (never panics on char boundaries).
274pub fn truncate_chars(s: &str, max_chars: usize) -> String {
275    if s.chars().count() <= max_chars {
276        return s.to_string();
277    }
278    let truncated: String = s.chars().take(max_chars).collect();
279    format!("{truncated}…")
280}
281
282/// Validated segment constructor (rejects NaN/inverted spans).
283pub fn validated_segment(start: f64, end: f64, text: impl Into<String>) -> Option<Segment> {
284    if !start.is_finite() || !end.is_finite() || end < start {
285        return None;
286    }
287    let text = text.into();
288    if text.trim().is_empty() {
289        return None;
290    }
291    Some(Segment::from_parts_unchecked(
292        start,
293        end,
294        text.trim().to_string(),
295    ))
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn detects_extreme_ngram_loop() {
304        let phrase = "feature and i think it's ";
305        let text = phrase.repeat(40);
306        let detail = detect_repetition_degeneration(&text).expect("should flag loop");
307        assert!(detail.contains("repeated") || detail.contains("fraction"));
308    }
309
310    #[test]
311    fn ordinary_text_not_flagged() {
312        let text = "There's something that a lot of people do when they're working with AI \
313                    to write code that totally drives me crazy and it's sort of something \
314                    that I've been railing against for a while now. The thing that I've \
315                    noticed is people tend to think I need to create a spec for AI.";
316        assert!(detect_repetition_degeneration(text).is_none());
317    }
318
319    #[test]
320    fn short_legitimate_repetition_ok() {
321        let text = "no no no I said yes yes yes please please please thank you thank you";
322        assert!(detect_repetition_degeneration(text).is_none());
323    }
324
325    #[test]
326    fn drops_nan_segments() {
327        let r = TranscriptionResult::local(
328            "x".into(),
329            vec![
330                Segment::from_parts_unchecked(f64::NAN, 1.0, "bad".to_string()),
331                Segment::from_parts_unchecked(0.0, 1.0, "good".to_string()),
332            ],
333            None,
334            "m".into(),
335            2.0,
336        );
337        let (out, report) = normalize_result_with_report(r);
338        assert_eq!(out.segments.len(), 1);
339        assert_eq!(out.segments[0].text(), "good");
340        assert!(report.dropped_segments >= 1);
341    }
342
343    #[test]
344    fn llm_backend_clears_reliable_flag() {
345        let mut r =
346            TranscriptionResult::openrouter("hi".into(), vec![], None, "m".into(), 1.0, true);
347        r.timestamps_reliable = true;
348        let (out, report) = normalize_result_with_report(r);
349        assert!(!out.timestamps_reliable);
350        assert!(!report.is_clean());
351    }
352
353    #[test]
354    fn validated_segment_rejects_inverted() {
355        assert!(validated_segment(2.0, 1.0, "x").is_none());
356        assert!(validated_segment(0.0, 1.0, "x").is_some());
357    }
358}