Files
Lumotia/crates/ai-formatting/src/pipeline.rs
Jake 53fe848979 feat(A.4 #29): plain-text pre-formatter before LLM cleanup
New crates/ai-formatting/src/to_plain_text.rs module with one public
function: to_plain_text(&[Segment]) -> String.

Rules the function enforces:
- each segment's text is whitespace-normalised (any run of unicode
  whitespace collapses to a single ASCII space, so tabs, newlines,
  and NBSPs never reach the LLM),
- empty and whitespace-only segments are dropped,
- remaining segments are joined with a single ASCII space,
- the joined string is normalised again (so a segment ending in a
  space followed by one starting in a space does not produce a double
  space) and trimmed end-to-end.

pipeline.rs's inline join is replaced with this call. Whisper's
timestamp fields (Segment.start / .end) are carried separately and
never reach the LLM by construction — the "timestamps stripped"
half of brief item #29's acceptance falls out of using Segment.text
alone. The work the module actually adds is whitespace discipline
and the tested boundary (empty input, empty-only input, NBSPs,
pathological whitespace runs, idempotence, double-space at join
boundaries).

Source: Scriberr PR #288 — feeding raw Whisper JSON (with timestamps
and per-segment structure) degraded cleanup quality; plain-text
input raised it back.
2026-04-22 08:34:04 +01:00

212 lines
6.7 KiB
Rust

use kon_core::constants::SMART_PARAGRAPH_GAP_SECS;
use kon_core::types::Segment;
use kon_llm::LlmEngine;
use crate::{llm_client, rule_based, to_plain_text::to_plain_text};
/// Post-processing options for a transcription pipeline run.
pub struct PostProcessOptions {
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
pub format_mode: FormatMode,
/// Custom vocabulary terms loaded from the user's dictionary. Injected
/// into the LLM cleanup prompt so the model knows how to spell them.
pub dictionary_terms: Vec<String>,
}
/// How aggressively to format the transcript text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormatMode {
Raw,
Clean,
Smart,
}
impl FormatMode {
pub fn parse(s: &str) -> Self {
match s {
"Clean" => Self::Clean,
"Smart" => Self::Smart,
_ => Self::Raw,
}
}
}
/// Apply all post-processing steps to a list of segments.
/// Modifies segments in place. Composed from individual pure functions.
pub fn post_process_segments(
segments: &mut Vec<Segment>,
options: &PostProcessOptions,
llm: Option<&LlmEngine>,
) {
if options.anti_hallucination {
segments.retain(|seg| !rule_based::is_hallucination(&seg.text));
}
for seg in segments.iter_mut() {
if options.remove_fillers {
seg.text = rule_based::remove_fillers(&seg.text);
}
if options.british_english {
seg.text = rule_based::to_british_english(&seg.text);
}
if options.format_mode != FormatMode::Raw {
seg.text = rule_based::collapse_repetitions(&seg.text);
seg.text = rule_based::format_text(&seg.text);
}
}
if options.format_mode == FormatMode::Smart && segments.len() > 1 {
for i in (1..segments.len()).rev() {
let gap = segments[i].start - segments[i - 1].end;
if gap > SMART_PARAGRAPH_GAP_SECS {
segments[i].text = format!("\n\n{}", segments[i].text.trim_start());
}
}
}
if let Some(engine) = llm {
if engine.is_loaded() && options.format_mode != FormatMode::Raw {
// Plain-text pre-formatter (brief item #29): collapse
// segments into a single natural-language string before
// the LLM call. Whitespace normalisation + empty-filter
// live in `to_plain_text`; the pipeline's job here is
// deciding whether to invoke the LLM at all.
let joined = to_plain_text(segments);
if !joined.is_empty() {
// Pipeline-internal cleanup (used by file-based + live
// transcribe paths) runs with the Default preset. The
// named-preset UX (B.1 #15) flows through the explicit
// cleanup_transcript_text_cmd path instead, where the
// frontend decides which preset the user has selected.
match llm_client::cleanup_text(
engine,
&joined,
&options.dictionary_terms,
llm_client::LlmPromptPreset::Default,
) {
Ok(cleaned) if !cleaned.trim().is_empty() => {
replace_segments_with_cleaned(segments, cleaned.trim());
}
Ok(_) => {}
Err(err) => eprintln!(
"[ai-formatting] LLM cleanup failed, keeping rule-based output: {err}"
),
}
}
}
}
}
fn replace_segments_with_cleaned(segments: &mut Vec<Segment>, cleaned: &str) {
if segments.is_empty() || cleaned.trim().is_empty() {
return;
}
let start = segments.first().map(|segment| segment.start).unwrap_or(0.0);
let end = segments.last().map(|segment| segment.end).unwrap_or(start);
segments.clear();
segments.push(Segment {
start,
end,
text: cleaned.to_string(),
});
}
#[cfg(test)]
mod tests {
use super::*;
fn make_segments() -> Vec<Segment> {
vec![
Segment {
start: 0.0,
end: 1.0,
text: "um hello world".into(),
},
Segment {
start: 1.0,
end: 2.0,
text: "[blank_audio]".into(),
},
Segment {
start: 5.0,
end: 6.0,
text: "organize the color scheme".into(),
},
]
}
#[test]
fn dictionary_terms_stored_on_options() {
let options = PostProcessOptions {
remove_fillers: false,
british_english: false,
anti_hallucination: false,
format_mode: FormatMode::Raw,
dictionary_terms: vec!["Wren".to_string(), "CORBEL".to_string()],
};
assert_eq!(options.dictionary_terms.len(), 2);
assert_eq!(options.dictionary_terms[0], "Wren");
}
#[test]
fn post_process_applies_all_filters() {
let mut segments = make_segments();
let options = PostProcessOptions {
remove_fillers: true,
british_english: true,
anti_hallucination: true,
format_mode: FormatMode::Clean,
dictionary_terms: vec![],
};
post_process_segments(&mut segments, &options, None);
assert_eq!(segments.len(), 2);
let lower0 = segments[0].text.to_lowercase();
let lower1 = segments[1].text.to_lowercase();
assert!(!lower0.contains("um"));
assert!(lower1.contains("organise"));
assert!(lower1.contains("colour"));
}
#[test]
fn post_process_adds_paragraph_breaks_on_long_pauses() {
let mut segments = make_segments();
let options = PostProcessOptions {
remove_fillers: false,
british_english: false,
anti_hallucination: false,
format_mode: FormatMode::Smart,
dictionary_terms: vec![],
};
post_process_segments(&mut segments, &options, None);
assert!(segments[2].text.starts_with("\n\n"));
}
#[test]
fn post_process_collapses_repeated_phrases_in_clean_modes() {
let mut segments = vec![Segment {
start: 0.0,
end: 1.0,
text: "I need I need to go to the shops".into(),
}];
let options = PostProcessOptions {
remove_fillers: false,
british_english: false,
anti_hallucination: false,
format_mode: FormatMode::Clean,
dictionary_terms: vec![],
};
post_process_segments(&mut segments, &options, None);
assert_eq!(segments[0].text, "I need to go to the shops");
}
}