fix(kon): normalise British English table, single-pass whitespace collapse, byte-index safety

- Normalise BRITISH_REPLACEMENTS: remove baked-in \b from entries so all
  entries are plain base words; the function adds boundaries uniformly
- Replace O(n*m) while-loop double-space removal with single-pass collapse
- Add debug_assert! documenting ASCII assumption for byte-indexed suffix slicing
- Expand llm_client.rs module-level doc comment
- Run cargo fmt on ai-formatting crate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-17 00:19:59 +00:00
parent 95c8d490c3
commit 2ac98e6d40
4 changed files with 81 additions and 88 deletions

View File

@@ -3,6 +3,4 @@ pub mod pipeline;
pub mod rule_based; pub mod rule_based;
pub use pipeline::{post_process_segments, FormatMode, PostProcessOptions}; pub use pipeline::{post_process_segments, FormatMode, PostProcessOptions};
pub use rule_based::{ pub use rule_based::{format_text, is_hallucination, remove_fillers, to_british_english};
format_text, is_hallucination, remove_fillers, to_british_english,
};

View File

@@ -1,2 +1,5 @@
// Stub for future LLM sidecar integration. //! Placeholder for future LLM sidecar integration (e.g., mistral.rs for smart formatting).
// Will implement TextProcessor trait when mistral.rs sidecar is added. //!
//! When implemented, this module will expose a client that sends transcription
//! segments to a local LLM for context-aware punctuation, paragraph splitting,
//! and stylistic cleanup beyond what the rule-based pipeline can achieve.

View File

@@ -31,10 +31,7 @@ impl FormatMode {
/// Apply all post-processing steps to a list of segments. /// Apply all post-processing steps to a list of segments.
/// Modifies segments in place. Composed from individual pure functions. /// Modifies segments in place. Composed from individual pure functions.
pub fn post_process_segments( pub fn post_process_segments(segments: &mut Vec<Segment>, options: &PostProcessOptions) {
segments: &mut Vec<Segment>,
options: &PostProcessOptions,
) {
if options.anti_hallucination { if options.anti_hallucination {
segments.retain(|seg| !rule_based::is_hallucination(&seg.text)); segments.retain(|seg| !rule_based::is_hallucination(&seg.text));
} }
@@ -55,8 +52,7 @@ pub fn post_process_segments(
for i in (1..segments.len()).rev() { for i in (1..segments.len()).rev() {
let gap = segments[i].start - segments[i - 1].end; let gap = segments[i].start - segments[i - 1].end;
if gap > SMART_PARAGRAPH_GAP_SECS { if gap > SMART_PARAGRAPH_GAP_SECS {
segments[i].text = segments[i].text = format!("\n\n{}", segments[i].text.trim_start());
format!("\n\n{}", segments[i].text.trim_start());
} }
} }
} }

View File

@@ -3,8 +3,7 @@ use std::sync::LazyLock;
/// Compiled filler word regexes (built once, reused across calls). /// Compiled filler word regexes (built once, reused across calls).
/// Uses \b word boundaries instead of lookbehinds (regex-lite does not /// Uses \b word boundaries instead of lookbehinds (regex-lite does not
/// support lookaround assertions). /// support lookaround assertions).
static FILLER_REGEXES: LazyLock<Vec<regex_lite::Regex>> = static FILLER_REGEXES: LazyLock<Vec<regex_lite::Regex>> = LazyLock::new(|| {
LazyLock::new(|| {
let fillers = [ let fillers = [
"um", "um",
"uh", "uh",
@@ -23,8 +22,7 @@ static FILLER_REGEXES: LazyLock<Vec<regex_lite::Regex>> =
.iter() .iter()
.filter_map(|filler| { .filter_map(|filler| {
let escaped = regex_lite::escape(filler); let escaped = regex_lite::escape(filler);
let pattern = let pattern = format!(r"(?i)\b{escaped}\b[,.]?\s*");
format!(r"(?i)\b{escaped}\b[,.]?\s*");
regex_lite::Regex::new(&pattern).ok() regex_lite::Regex::new(&pattern).ok()
}) })
.collect() .collect()
@@ -38,14 +36,29 @@ pub fn remove_fillers(text: &str) -> String {
result = re.replace_all(&result, " ").to_string(); result = re.replace_all(&result, " ").to_string();
} }
while result.contains(" ") { // Collapse runs of whitespace in a single pass.
result = result.replace(" ", " "); let mut collapsed = String::with_capacity(result.len());
let mut prev_space = false;
for ch in result.chars() {
if ch == ' ' {
if !prev_space {
collapsed.push(' ');
}
prev_space = true;
} else {
prev_space = false;
collapsed.push(ch);
}
} }
result.trim().to_string() collapsed.trim().to_string()
} }
/// Replacement pairs for American British English conversion. /// Replacement pairs for American to British English conversion.
///
/// All entries are plain base words (no regex metacharacters). The
/// `to_british_english` function wraps every entry with `\b` word
/// boundaries and optional suffix matching automatically.
static BRITISH_REPLACEMENTS: &[(&str, &str)] = &[ static BRITISH_REPLACEMENTS: &[(&str, &str)] = &[
// -ize → -ise (and inflected forms) // -ize → -ise (and inflected forms)
("organize", "organise"), ("organize", "organise"),
@@ -74,26 +87,26 @@ static BRITISH_REPLACEMENTS: &[(&str, &str)] = &[
("standardize", "standardise"), ("standardize", "standardise"),
("summarize", "summarise"), ("summarize", "summarise"),
("utilize", "utilise"), ("utilize", "utilise"),
// -or → -our (word-boundary needed) // -or → -our
("\\bcolor\\b", "colour"), ("color", "colour"),
("\\bfavor\\b", "favour"), ("favor", "favour"),
("\\bhonor\\b", "honour"), ("honor", "honour"),
("\\bhumor\\b", "humour"), ("humor", "humour"),
("\\blabor\\b", "labour"), ("labor", "labour"),
("\\bneighbor\\b", "neighbour"), ("neighbor", "neighbour"),
("behavior", "behaviour"), ("behavior", "behaviour"),
// -er → -re (word-boundary to avoid "parameter" etc.) // -er → -re
("\\bcenter\\b", "centre"), ("center", "centre"),
("\\bfiber\\b", "fibre"), ("fiber", "fibre"),
("\\bliter\\b", "litre"), ("liter", "litre"),
("\\bmeter\\b", "metre"), ("meter", "metre"),
("\\btheater\\b", "theatre"), ("theater", "theatre"),
// -ense → -ence // -ense → -ence
("defense", "defence"), ("defense", "defence"),
("offense", "offence"), ("offense", "offence"),
// Other // Other
("\\bcatalog\\b", "catalogue"), ("catalog", "catalogue"),
("\\bdialog\\b", "dialogue"), ("dialog", "dialogue"),
]; ];
/// Convert American English spelling to British English (word-boundary aware). /// Convert American English spelling to British English (word-boundary aware).
@@ -101,14 +114,9 @@ pub fn to_british_english(text: &str) -> String {
let mut result = text.to_string(); let mut result = text.to_string();
for (us, uk) in BRITISH_REPLACEMENTS { for (us, uk) in BRITISH_REPLACEMENTS {
let pattern = if us.contains("\\b") { // Every entry in BRITISH_REPLACEMENTS is a plain ASCII base word.
format!("(?i){us}") // We wrap it with \b boundaries and optional suffix matching here.
} else { let pattern = format!("(?i)\\b{}(?:d|s|r|rs)?\\b", regex_lite::escape(us));
format!(
"(?i)\\b{}(?:d|s|r|rs)?\\b",
regex_lite::escape(us)
)
};
if let Ok(re) = regex_lite::Regex::new(&pattern) { if let Ok(re) = regex_lite::Regex::new(&pattern) {
result = re result = re
@@ -117,8 +125,12 @@ pub fn to_british_english(text: &str) -> String {
return String::new(); return String::new();
}; };
let matched = m.as_str(); let matched = m.as_str();
let us_base = us.replace("\\b", ""); let base_len = us.len();
let base_len = us_base.len(); // SAFETY: byte indexing is correct here because both
// the US base word and the suffix characters (d, s, r)
// are guaranteed ASCII by the BRITISH_REPLACEMENTS table.
debug_assert!(us.is_ascii(), "BRITISH_REPLACEMENTS entries must be ASCII");
debug_assert!(matched.is_ascii(), "matched text expected to be ASCII");
let suffix = if matched.len() > base_len { let suffix = if matched.len() > base_len {
&matched[base_len..] &matched[base_len..]
} else { } else {
@@ -127,22 +139,15 @@ pub fn to_british_english(text: &str) -> String {
let Some(first_char) = matched.chars().next() else { let Some(first_char) = matched.chars().next() else {
return String::new(); return String::new();
}; };
let uk_clean = uk.replace("\\b", "");
if first_char.is_uppercase() { if first_char.is_uppercase() {
let mut chars = uk_clean.chars(); let mut chars = uk.chars();
let Some(first_uk) = chars.next() else { let Some(first_uk) = chars.next() else {
return String::new(); return String::new();
}; };
let upper_first: String = let upper_first: String = first_uk.to_uppercase().collect();
first_uk.to_uppercase().collect(); format!("{}{}{}", upper_first, chars.collect::<String>(), suffix)
format!(
"{}{}{}",
upper_first,
chars.collect::<String>(),
suffix
)
} else { } else {
format!("{uk_clean}{suffix}") format!("{uk}{suffix}")
} }
}) })
.to_string(); .to_string();
@@ -193,18 +198,9 @@ pub fn format_text(text: &str) -> String {
} }
/// Known hallucination markers that should be filtered from transcriptions. /// Known hallucination markers that should be filtered from transcriptions.
static HALLUCINATION_MARKERS: &[&str] = &[ static HALLUCINATION_MARKERS: &[&str] = &["[blank_audio]", "[music]", "[silence]"];
"[blank_audio]",
"[music]",
"[silence]",
];
static AUTO_THANKS_PHRASES: &[&str] = &[ static AUTO_THANKS_PHRASES: &[&str] = &["thank you.", "thanks.", "you.", "thank you for watching."];
"thank you.",
"thanks.",
"you.",
"thank you for watching.",
];
/// Returns true if a segment's text looks like a hallucination. /// Returns true if a segment's text looks like a hallucination.
pub fn is_hallucination(text: &str) -> bool { pub fn is_hallucination(text: &str) -> bool {