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