Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.
transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
-> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
as immutable audit trail). Includes architecture-map references
to magnotia_core::*, magnotia_storage::*, etc. now pointing at
lumotia_*; dev-setup.md tracing output examples (lumotia_startup
target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
("lumotia_task_sync"); magnotia_locale i18n localStorage key
renamed + migration shim added; CSS keyframe names
magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
to "lumotia era" earlier — restored).
Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
legacy detection strings in legacy_and_target_paths() so the
migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
— migration call sites reference the legacy magnotia keys
deliberately.
- docs/handovers/ — historical audit trail.
cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
224 lines
7.7 KiB
Rust
224 lines
7.7 KiB
Rust
//! Plain-text pre-formatter for LLM cleanup.
|
|
//!
|
|
//! Brief item #29: before sending transcription segments to the LLM,
|
|
//! join them into a single natural-language string with timestamps
|
|
//! stripped and whitespace normalised. Source: Scriberr PR #288 —
|
|
//! feeding raw Whisper JSON (with its timestamps and per-segment
|
|
//! structure) degraded cleanup quality materially; plain-text input
|
|
//! raised it back.
|
|
//!
|
|
//! `Segment.text` in Lumotia already holds just the spoken text (the
|
|
//! `start`/`end` f64 fields carry the timing), so "timestamp
|
|
//! stripping" falls out of using the text field alone. The work here
|
|
//! is the whitespace pass and empty-segment filter, plus a single
|
|
//! public function the pipeline can depend on.
|
|
|
|
use lumotia_core::types::Segment;
|
|
|
|
/// Join transcription segments into a single plain-text string
|
|
/// suitable for feeding to an LLM cleanup prompt.
|
|
///
|
|
/// Rules:
|
|
/// - each segment's text is whitespace-normalised (any run of
|
|
/// whitespace — spaces, tabs, newlines, non-breaking spaces —
|
|
/// collapses to a single ASCII space),
|
|
/// - segments that are empty or whitespace-only are dropped,
|
|
/// - the remaining segments are joined with a single ASCII space,
|
|
/// - the final string is whitespace-normalised again (so a segment
|
|
/// ending in a space and the next beginning with one do not produce
|
|
/// a double space) and trimmed of leading/trailing whitespace.
|
|
///
|
|
/// Pure function. No panics. Returns an empty string if every segment
|
|
/// filters out.
|
|
pub fn to_plain_text(segments: &[Segment]) -> String {
|
|
let joined = segments
|
|
.iter()
|
|
.map(|s| normalise_whitespace(&s.text))
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
normalise_whitespace(&joined).trim().to_string()
|
|
}
|
|
|
|
/// Collapse any run of unicode whitespace into a single ASCII space,
|
|
/// and strip zero-width format characters entirely.
|
|
///
|
|
/// Zero-width chars (U+200B/C/D, U+2060, U+FEFF) are handled as a
|
|
/// separate class from whitespace: `char::is_whitespace()` returns
|
|
/// false for them, so the standard whitespace pass would let them
|
|
/// through to the LLM where they waste tokens without contributing
|
|
/// any natural-language content. Treating them as "strip entirely"
|
|
/// rather than "collapse to a space" avoids silently inserting word
|
|
/// breaks where the source had none.
|
|
///
|
|
/// Kept private; the module's contract is `to_plain_text`.
|
|
fn normalise_whitespace(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut prev_was_space = false;
|
|
for ch in s.chars() {
|
|
if is_zero_width_format(ch) {
|
|
// Strip without emitting anything. prev_was_space unchanged
|
|
// so a space on either side of a zero-width char still
|
|
// collapses correctly.
|
|
continue;
|
|
}
|
|
if ch.is_whitespace() {
|
|
if !prev_was_space {
|
|
out.push(' ');
|
|
prev_was_space = true;
|
|
}
|
|
} else {
|
|
out.push(ch);
|
|
prev_was_space = false;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Zero-width format characters the transcription pipeline should
|
|
/// never feed to an LLM. Sourced from common "invisible" codepoints:
|
|
/// - U+200B ZERO WIDTH SPACE
|
|
/// - U+200C ZERO WIDTH NON-JOINER
|
|
/// - U+200D ZERO WIDTH JOINER
|
|
/// - U+2060 WORD JOINER
|
|
/// - U+FEFF ZERO WIDTH NO-BREAK SPACE (also BOM)
|
|
fn is_zero_width_format(ch: char) -> bool {
|
|
matches!(
|
|
ch,
|
|
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}'
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn seg(text: &str) -> Segment {
|
|
Segment {
|
|
start: 0.0,
|
|
end: 1.0,
|
|
text: text.into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_is_empty_output() {
|
|
assert_eq!(to_plain_text(&[]), "");
|
|
}
|
|
|
|
#[test]
|
|
fn single_segment_returns_its_text_trimmed() {
|
|
let out = to_plain_text(&[seg(" hello world ")]);
|
|
assert_eq!(out, "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_segments_are_joined_with_single_space() {
|
|
let out = to_plain_text(&[seg("the cat"), seg("sat on the mat")]);
|
|
assert_eq!(out, "the cat sat on the mat");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_and_whitespace_segments_are_filtered() {
|
|
let out = to_plain_text(&[
|
|
seg("hello"),
|
|
seg(""),
|
|
seg(" "),
|
|
seg("\n\t "),
|
|
seg("world"),
|
|
]);
|
|
assert_eq!(out, "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn internal_whitespace_runs_collapse_to_single_space() {
|
|
let out = to_plain_text(&[seg("hello\t\t \nworld")]);
|
|
assert_eq!(out, "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn join_boundary_does_not_produce_double_spaces() {
|
|
// First segment ends with whitespace, next starts with it —
|
|
// naive join would produce "foo bar".
|
|
let out = to_plain_text(&[seg("foo "), seg(" bar")]);
|
|
assert_eq!(out, "foo bar");
|
|
}
|
|
|
|
#[test]
|
|
fn non_breaking_space_is_treated_as_whitespace() {
|
|
// \u{00A0} is NBSP — char::is_whitespace returns true for it.
|
|
// LLM cleanup should not see NBSP leaked in.
|
|
let out = to_plain_text(&[seg("hello\u{00A0}world")]);
|
|
assert_eq!(out, "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn zero_width_format_chars_strip_entirely() {
|
|
// char::is_whitespace returns false for all of these, so the
|
|
// default whitespace pass would let them through. They carry
|
|
// no natural-language content — stripping them saves LLM
|
|
// tokens without changing meaning.
|
|
let cases = [
|
|
("hello\u{200B}world", "helloworld"), // ZERO WIDTH SPACE
|
|
("hello\u{200C}world", "helloworld"), // ZWNJ
|
|
("hello\u{200D}world", "helloworld"), // ZWJ
|
|
("hello\u{2060}world", "helloworld"), // WORD JOINER
|
|
("hello\u{FEFF}world", "helloworld"), // ZWNBSP / BOM
|
|
];
|
|
for (input, expected) in cases {
|
|
let out = to_plain_text(&[seg(input)]);
|
|
assert_eq!(
|
|
out, expected,
|
|
"input {input:?} should strip to {expected:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn zero_width_chars_do_not_break_adjacent_whitespace_collapsing() {
|
|
// "hello \u{FEFF} world" — the zero-width char between two
|
|
// spaces should strip, leaving a single collapsed space.
|
|
let out = to_plain_text(&[seg("hello \u{FEFF} world")]);
|
|
assert_eq!(out, "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn leading_bom_is_stripped() {
|
|
// BOM at start of segment — common artifact when Whisper
|
|
// consumes a file whose encoding pass inserted one.
|
|
let out = to_plain_text(&[seg("\u{FEFF}hello world")]);
|
|
assert_eq!(out, "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn newlines_inside_segments_collapse() {
|
|
let out = to_plain_text(&[seg("line one\nline two\n\nline three")]);
|
|
assert_eq!(out, "line one line two line three");
|
|
}
|
|
|
|
#[test]
|
|
fn idempotent_on_already_normalised_text() {
|
|
// If the pipeline ever calls us twice, the second call must
|
|
// not mangle the result.
|
|
let once = to_plain_text(&[seg("hello world"), seg("foo bar")]);
|
|
let twice = to_plain_text(&[seg(&once)]);
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
#[test]
|
|
fn only_empty_segments_yields_empty_string() {
|
|
let out = to_plain_text(&[seg(""), seg(" "), seg("\t")]);
|
|
assert_eq!(out, "");
|
|
}
|
|
|
|
#[test]
|
|
fn no_panic_on_pathological_whitespace_runs() {
|
|
// A segment that is 10k spaces long normalises in linear time
|
|
// without panicking on capacity guesses.
|
|
let big_spaces = " ".repeat(10_000);
|
|
let out = to_plain_text(&[seg(&format!("a{big_spaces}b"))]);
|
|
assert_eq!(out, "a b");
|
|
}
|
|
}
|