Files
Lumotia/crates/ai-formatting/src/pipeline.rs
Jake 089349d966
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — rust workspace crates magnotia-* -> lumotia-*
Phase 2 of the rebrand cascade. Renames all 9 workspace crates from
magnotia-* to lumotia-* plus the src-tauri binary crate name:

- magnotia-ai-formatting   -> lumotia-ai-formatting
- magnotia-audio           -> lumotia-audio
- magnotia-cloud-providers -> lumotia-cloud-providers
- magnotia-core            -> lumotia-core
- magnotia-hotkey          -> lumotia-hotkey
- magnotia-llm             -> lumotia-llm
- magnotia-mcp             -> lumotia-mcp
- magnotia-storage         -> lumotia-storage
- magnotia-transcription   -> lumotia-transcription
- magnotia                 -> lumotia (src-tauri binary)
- magnotia_lib             -> lumotia_lib (src-tauri lib target)

Crate directories (crates/audio/ etc.) stay as-is; only the Cargo.toml
[package] name field changes plus all consumer module imports
(magnotia_core -> lumotia_core, etc.).

Remaining magnotia_* references at this point are intentional and
scoped to later phases: tracing targets (Phase 4), DB setting keys
magnotia_preferences/magnotia_history (Phase 5).

cargo build --workspace passes. cargo test --workspace: 330 pass, 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:48:09 +01:00

213 lines
6.7 KiB
Rust

use lumotia_core::constants::SMART_PARAGRAPH_GAP_SECS;
use lumotia_core::types::Segment;
use lumotia_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) => tracing::warn!(
error = %err,
"LLM cleanup failed, keeping rule-based output"
),
}
}
}
}
}
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");
}
}