diff --git a/LESSONS.md b/LESSONS.md index 797ee9ca41..510b2c40da 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -40,3 +40,4 @@ the row is `open`, so several start past the 14-day limit and need a decision. | L-011 | 2026-10-16 | The first in-room plan listed `pyannote-rs` as a fallback engine. The M0 probe found it does not compile against Kōrero's `ort` (29 errors), ignores the model's speaker-change output and drifts about 60 ms per 10 s. | Compile a dependency and read its source before naming it in a plan, even as a fallback. | advice-only | `app-dev-pm` Phase 1.5 already requires probing claims; this was a planning slip, caught by M0 before any app code. | | L-012 | 2026-10-16 | M0's first scores were worse than calling everyone one speaker. The cause was the CAM++ embedding model packaged with `pyannote-rs`: it ran without error and gave stable output, but told AMI speakers apart no better than chance, even on headset audio. The harness was checked first (reference timing, feature scale, window type), then models were compared on clean labelled turns; WeSpeaker ResNet34-LM separated them 82–100% of the time. | A model that runs is not a model that works: score it on labelled data, and check the harness before trusting a bad number. | check-added | `diar_ami_der` in `src-tauri/src/diarise.rs` scores the shipped models on four AMI far-field meetings and fails above a 25% mean error (manual: 177 MB of audio, not in CI). Its own scorer agrees with pyannote.metrics to within 0.2 points. | | L-013 | 2026-10-16 | Editing `src/bindings.ts` with a script that normalised line endings turned a 31-line change into a 2,003-line diff: the file is stored in git with mixed CRLF and LF lines, and the rewrite made them uniform. The same session then wrote garbled macrons into a decision row, because Windows PowerShell 5.1 read a script saved without a byte-order mark as ANSI. Both were caught before any push. | Before committing a scripted edit, compare `git diff --numstat` with and without `--ignore-cr-at-eol`, and search the added lines for mojibake (Ã, Å); write text with macrons from Python or a UTF-8-with-BOM script. | open | A `.gitattributes` rule for `src/bindings.ts` (or regenerating it in CI) and a mojibake check on added lines would make these checks. | +| L-014 | 2026-10-23 | Dictation clean-up failed whenever the local model was not already loaded and took over 30 s to load (twice on 23 Oct; one cold load measured 108 s with 3 GB of RAM free). The 30 s limit covered loading as well as answering, and giving up cancelled the request, which made Ollama abort the half-finished load, so the next try started from nothing. The first look at the evidence also went wrong: Windows showed `korero.log` as 0 bytes (a stale directory size for a file open for append), and the log rotation (keep one, 500 KB) had already deleted the record of the earlier failure. | A time limit on a request must not also cover a cost that varies tenfold with the machine's state (here, loading a model); separate the wait from the work, and never cancel work whose result is still wanted. Read a log by opening it, not by its listed size. Keep more than one log file. | check-added | D-010 (1.45.1): `cleanup_wait_tests` (giving up at the cursor does not cancel the clean-up; only Ollama may finish late; the pre-warm leaves room for a dictation), `late_clean_up_fills_only_an_empty_entry`, `dictation_and_warm_outlive_a_slow_cold_load`, and the live `live_cold_dictation_survives_the_cursor_wait`; log rotation keeps four earlier files. | diff --git a/README.md b/README.md index e6f96dfcdb..be49f50488 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Two things do reach the network, and it is worth naming them rather than roundin speech models are downloaded on first use, and the app asks GitHub once at startup whether a newer release exists. Neither carries audio, transcripts, or anything about you. No telemetry, ever. -Current version: **v1.45.0** ([release notes](https://github.com/nkeating-cmd/korero/releases/tag/v1.45.0)). +Current version: **v1.45.1** ([release notes](https://github.com/nkeating-cmd/korero/releases/tag/v1.45.1)). --- @@ -51,6 +51,7 @@ Current version: **v1.45.0** ([release notes](https://github.com/nkeating-cmd/ko - **Latch / hands-free mode** — double-tap a shortcut to lock recording on for long dictation; tap once to stop. ### Reliability +- **Clean-up survives a slow local model** (v1.45.1, Ollama). When you start a clean-up dictation and your Ollama model isn't loaded, Kōrero starts loading it straight away, so the load happens while you speak. If the clean-up still isn't ready 30 seconds after you stop, your words go in as you said them, the pill says *Pasted as spoken · clean-up continues*, and the clean-up keeps going: if it succeeds, the cleaned version appears on that dictation in History (it never replaces a correction you made in the meantime). Before, a model that took longer than 30 seconds to load lost the clean-up entirely, and giving up made Ollama throw away the half-loaded model. Cloud and custom providers keep their 30-second limit. Kōrero also keeps its last five log files instead of one. - **Nothing lost, nothing hidden** (v1.42). If a paste fails, the text goes to the clipboard and the dictation pill says so. No microphone shows on the pill with a Fix button. A note's dictation always lands in the note it started in, even if you change page, and a failed clean-up is labelled instead of passing as done. - **Meetings keep working when you leave the tab** (v1.41). Stop, re-transcribe, import, recover, notes and refine all run in the background, and the spinner and progress are still there when you come back. One queue now handles every read and write of the meetings store, so a page you return to can no longer save its older copy over a finished result. - **Capture warnings while there's still time to fix them** (v1.41). If the microphone hears nothing for two minutes, or almost nothing comes through from the computer's audio output after 90 seconds, Kōrero warns you mid-meeting with a toast and a taskbar flash. Stop explains why a side came back empty. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c56bc53002..a128f32e96 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -21,3 +21,4 @@ in the Kōrero claude.ai project), where some carried older labels (shown in bra | D-007 | 2026-10-16 | Accepted | **In-room speaker separation runs in Kōrero's own code on its existing ONNX runtime** (`ort 2.0.0-rc.12`), after Stop, on the CPU, never live: pyannote segmentation-3.0 (MIT) read for speaker changes, not only speech; WeSpeaker ResNet34-LM speaker embeddings (CC BY 4.0, attribution in the third-party notices); clustering of the whole meeting on segments of 1.5 s or more, clusters under 20 s of speech folded into the nearest one, short fragments attached by voice. Plan: `KORERO_1.45_IN-ROOM-SPEAKERS-PLAN_2026-10-16.md` (v2). (Approved by Nic 16 Oct 2026: "Execute D007".) | M0 probe, 16 Oct, on four AMI test meetings recorded by one far-field room microphone (92 minutes): mean error 23.1% with a 0.25 s collar, 28.1% strict, against 22.4% strict for pyannote's own pipeline on all 16 test meetings; right number of people in 2 of 4 meetings (one too many in one, two too few in the other). About 80 s of CPU time per hour of audio on 4 threads. It builds in the same binary as `transcribe-rs` with one `ort`. | `pyannote-rs` 0.3.4: does not compile against `ort` rc.12 (29 errors); reads the model only as a speech detector; its clock drifts 60.6 ms per 10 s; its packaged CAM++ embedding model told AMI speakers apart no better than chance, even on headset audio (57.5% error with the right model, 80.5% as published). `sherpa-onnx`: a second ONNX runtime beside `ort`. Telling the clusterer the number of people: worse (31.5%) than finding it. The notes model guessing speakers: invents attributions. | Results on Nic's own room recordings differ from AMI; `knf-rs` (C++ build, `ndarray` 0.16) is replaced by Rust feature code. | | D-008 | 2026-10-16 | Accepted | **The speaker-separation models are downloaded on first use, not bundled.** "Separate speakers" fetches pyannote segmentation-3.0 (5,983,836 B) and WeSpeaker ResNet34-LM (26,530,550 B) into the models folder (`diarise/`), writes each as `.partial`, and keeps it only if its size and sha256 match the values pinned in `diarise.rs`; a mismatch is deleted and never loaded. Sources for now: the `pyannote-rs` v0.1.0 and sherpa-onnx speaker-recognition-models GitHub releases. Filterbank features come from `knf-rs` 0.3.2 (MIT; a C++ build through CMake and bindgen, and a second `ndarray`, 0.16, internal to it). | Bundling would roughly triple the installer (14 MB to about 46 MB) for every user, including those who never record in a room. Download-on-first-use matches how Kōrero already fetches speech models and stays offline afterwards. Pinning size and hash means a changed or substituted file at either URL fails closed. | Bundle in the installer: simplest offline story, but the size cost falls on everyone. Mirror to Kōrero's own GitHub release now: more control, but publishing a release is Nic's call. Rust filterbanks instead of `knf-rs`: removes the C++ build, but needs a parity check first (plan M2 step c). | Either upstream URL changes or disappears (the pinned hash makes that a clean failure, not a silent one): mirror both files to a Kōrero release. The Rust filterbank parity check passes (drop `knf-rs`). | | D-009 | 2026-10-17 | Accepted | **Meetings are chosen as "On a call" or "In the room" before Record** (remembered on the computer). In the room: the microphone only is recorded (no system capture, so no call-audio warnings); the live transcript is a preview with a lower energy floor (0.003, Silero still deciding what is speech); and after Stop the saved transcript is rebuilt from the speaker model's turns, one line per turn with its speaker and true start time (`diarise::meeting_room_transcript`). If that fails or finds nothing, the live lines are kept. (Nic, 17 Oct: "there should be the ability to select if it is a in-person meeting up front, so it knows there is nothing on the call".) | On four AMI room-microphone meetings, the fixed 0.010 floor passed 2-9% of labelled speech in three (median speech frame RMS 0.0024-0.0029; 57% in the fourth, a louder room), so live capture and the energy-based rebuild at Stop both lose most far-field speech. The segmentation model misses only 7-15% there (M0). Transcribing per turn also removes the estimated line ends and the mid-sentence speaker changes of D-007's labelling. | A lower fixed floor everywhere: rooms vary five-fold in level, and calls would pass more noise. An adaptive noise-floor gate: more moving parts and still energy-based; worth it later for calls. Asking the user for the number of people: made separation worse in M0. | Results on Nic's own rooms; overlapping speech (transcribed once, for whoever was already speaking); Hybrid meetings (B8). | +| D-010 | 2026-10-23 | Accepted | **A local Ollama dictation clean-up is never cancelled for running late.** The person at the cursor waits at most 30 s (`actions::CURSOR_WAIT`, as before). If the clean-up is not back by then, the words are pasted as spoken and the pill says "Pasted as spoken · clean-up continues". The clean-up carries on as its own task (up to `ollama_chat::DICTATION_LIMIT`, 300 s) and is filed on that dictation's History entry only while the entry still holds the same words and has no clean-up. Other providers (cloud, `custom`) keep their own 30 s client limit and are waited for as before, because a late result could never arrive (`can_finish_late`). History retry and Notes wait for the answer. When a clean-up dictation starts and the Ollama model is **not loaded at all**, Kōrero loads it (`prewarm_cleanup_model` → `warm_if_cold`; no transcript text sent) at a context with room for about 4,000 characters of dictation (`PREWARM_TRANSCRIPT_CHARS`, 6,144 tokens with the default prompt), so the request reuses that load. A pre-warm may run 300 s (was 120 s). Bug fix; Nic, 23 Oct: "make sure that what you fix is the best fix that's as persistent as possible". | Two clean-ups on 23 Oct on the main laptop failed at 30.1 s with the model still loading (Ollama log 13:47 and 14:17; Kōrero log: `loaded=None`, "didn't finish its answer within 30 s"). Cold loads of gemma4:12b took 14-19 s on 19-20 Oct and 108 s on 23 Oct with about 3 GB of RAM free, so no fixed wait at the cursor covers every load. Probes on 23 Oct: when the request waiting on a load disconnects, Ollama aborts the load even if another request is waiting (B), so each give-up threw the half-loaded model away; a request sent while a pre-warm is loading at the same context joins that load (C: one load, answered 17.6 s after sending). Live test, cold model: answered 35.0 s after sending, one load, no abort. Costs accepted, measured or bounded: loading the model while recording slowed transcribing a 30 s clip by 0.2-0.3 s (2.55-2.86 s alone, 2.77-3.17 s with a cold load running; two runs each, 3 GB RAM free); a cancelled recording leaves the model loaded for its 30 min keep-alive; late clean-ups are not capped and each can hold an Ollama request for up to 300 s; a dictation over about 4,000 characters still reloads the model at a bigger context. An independent review found three Major issues, fixed before merge: the late path and its pill text applied to providers that can never finish late; a pre-warm sized for the prompt alone made long dictations load the model twice; the overlap with transcription was unmeasured. | Raise the 30 s limit to cover the slowest load: a paste arriving two minutes after Stop lands in whatever window has focus by then, and 108 s may not be the worst. Keep the model loaded all day (`keep_alive` -1): holds about 7.5 GB of graphics memory whether or not anyone dictates, against D-004. Warn only: tells the person, but the clean-up is still lost and the load still thrown away. Skip the pre-warm when memory is low: the measured cost was small, and the low-memory case is where the pre-warm saves most. | Ollama stops aborting a shared load when one waiter leaves; another local runtime is added (`custom` has neither the late path nor a pre-warm); late clean-ups pile up in practice; people find the late History update confusing. | diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 39df16c1d7..155833c0e5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3285,7 +3285,7 @@ dependencies = [ [[package]] name = "korero" -version = "1.45.0" +version = "1.45.1" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5e54f7ab70..653779481d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "korero" -version = "1.45.0" +version = "1.45.1" description = "Kōrero — speech-to-text, NZ English" authors = ["Kyt Services Ltd ", "cjpais (upstream)"] edition = "2021" diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 1f327e2027..cf18a1ebae 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -17,7 +17,7 @@ use log::{debug, error, warn}; use once_cell::sync::Lazy; use std::collections::HashMap; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use tauri::Manager; use tauri::{AppHandle, Emitter}; use tauri_plugin_clipboard_manager::ClipboardExt; @@ -339,7 +339,7 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) } Err(e) => { error!( - "LLM post-processing failed for provider '{}': {}. Falling back to original transcription.", + "LLM post-processing failed for provider '{}': {} Falling back to original transcription.", provider.id, e ); @@ -392,39 +392,225 @@ async fn maybe_convert_chinese_variant( } } +/// Kōrero 1.45.1 (D-010): how long a dictation waits at the text cursor for its +/// clean-up before the words are pasted as spoken. Unchanged from the old 30 s +/// request limit, so a warm model behaves exactly as before; what changed is +/// that running out of this wait no longer cancels the clean-up (see +/// `ollama_chat::DICTATION_LIMIT`). Kept short on purpose: a paste that +/// arrives minutes later lands in whatever window has focus by then. +pub(crate) const CURSOR_WAIT: Duration = Duration::from_secs(30); + +/// Whether the caller can wait for a clean-up for as long as it takes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CleanupWait { + /// A dictation: someone is at a text cursor. Wait up to `CURSOR_WAIT`, + /// then hand back the words as spoken and finish the clean-up later. + AtCursor, + /// History retry and Notes: nothing is pasted, so wait for the answer. + UntilDone, +} + +/// Wait for a clean-up task as `wait` allows. `Some(result)` when it finished +/// (`result` is `None` if it failed), `None` when the cursor wait ran out first. +/// Giving up here leaves the task running: the handle is only borrowed. +async fn await_cleanup( + handle: &mut tauri::async_runtime::JoinHandle>, + wait: CleanupWait, + cursor_wait: Duration, +) -> Option> { + match wait { + CleanupWait::UntilDone => Some(handle.await.ok().flatten()), + CleanupWait::AtCursor => tokio::time::timeout(cursor_wait, handle) + .await + .ok() + .map(|joined| joined.ok().flatten()), + } +} + +/// A clean-up still running when the cursor wait ran out (D-010). +pub(crate) struct LateCleanup { + handle: tauri::async_runtime::JoinHandle>, + prompt: Option, +} + +/// D-010: only a local Ollama clean-up can outlast the cursor wait. Every +/// other path (cloud providers, `custom`, an `ollama` entry pointing at a +/// non-Ollama server) has its own 30 s client limit (`llm_client`), so a +/// "late" result would never arrive (independent review, M1). Those wait +/// as before: their own limit bounds them. +fn can_finish_late(settings: &AppSettings) -> bool { + settings + .active_post_process_provider() + .is_some_and(|p| p.id == "ollama") +} + pub(crate) struct ProcessedTranscription { pub final_text: String, pub post_processed_text: Option, pub post_process_prompt: Option, + /// Set when `final_text` is the words as spoken because the clean-up had + /// not finished within `CURSOR_WAIT`. Hand it to `finish_late_cleanup`. + pub late_cleanup: Option, +} + +/// D-010: let a clean-up that outlasted the cursor wait finish, and file the +/// result on the dictation's History entry (`entry_id`), which then shows the +/// cleaned text in place of "Post-processing unavailable". Never pastes: by +/// now the person may be typing somewhere else. `spoken` is the transcript the +/// clean-up read; the result is filed only while the entry still holds it. +pub(crate) fn finish_late_cleanup( + app: &AppHandle, + late: LateCleanup, + entry_id: Option, + spoken: String, +) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let started = Instant::now(); + let result = late.handle.await.ok().flatten(); + match (result, entry_id) { + (Some(text), Some(id)) => { + let hm = app.state::>(); + match hm.set_late_post_process(id, text, late.prompt, &spoken) { + Ok(Some(_)) => log::info!( + "Late clean-up filed in History entry {} after a further {:?}", + id, + started.elapsed() + ), + Ok(None) => log::info!( + "Late clean-up for History entry {} not filed: it was cleaned, edited, \ + re-transcribed or deleted in the meantime", + id + ), + Err(e) => error!("Late clean-up finished but could not be saved: {}", e), + } + } + (Some(_), None) => { + log::info!("Late clean-up finished; this dictation has no History entry to hold it") + } + (None, _) => { + warn!("Late clean-up did not finish either; History keeps the words as spoken") + } + } + }); +} + +/// The text of the prompt clean-up uses (stored with the History entry): the +/// per-app routed prompt when one applies, as `post_process_transcription` +/// chooses it. Until 1.45.1 History stored the globally selected prompt even +/// when a routed one was used (review, Minor 4). +fn resolved_prompt_text(settings: &AppSettings) -> Option { + let prompt_id = resolve_prompt_id(settings)?; + settings + .post_process_prompts + .iter() + .find(|prompt| prompt.id == prompt_id) + .map(|prompt| prompt.prompt.clone()) +} + +/// Characters of transcript the pre-warm leaves room for (D-010, review M2). +/// Sized so a dictation of up to about 4,000 characters (some three minutes +/// of speech) reuses the warmed model; with the default clean-up prompt that +/// is a 6,144-token context. A longer dictation asks for a bigger context, +/// and Ollama reloads the model for it. +const PREWARM_TRANSCRIPT_CHARS: usize = 4_000; + +/// Kōrero 1.45.1 (D-010): start loading the local clean-up model when a +/// clean-up dictation starts, so the load (15-19 s on the test laptop, 108 s +/// when it was short of memory) runs while the person is still speaking. A +/// clean-up request that arrives during the load joins it (probe C, 23 Oct: +/// one load, answered 17.6 s after it was sent, against about 20 s cold) when +/// the context sizes match, which `PREWARM_TRANSCRIPT_CHARS` arranges for +/// dictations up to about 4,000 characters. +/// +/// Ollama only: other providers have no load to hide, and a cloud provider is +/// never contacted before there is something to send. Sends no transcript +/// text. Only when the model is not loaded at all. Best-effort, never blocks. +fn prewarm_cleanup_model(app: &AppHandle) { + let settings = get_settings(app); + if !settings.post_process_enabled { + return; + } + let Some(provider) = settings.active_post_process_provider().cloned() else { + return; + }; + if provider.id != "ollama" { + return; + } + let model = settings + .post_process_models + .get(&provider.id) + .cloned() + .unwrap_or_default(); + if model.trim().is_empty() { + return; + } + let prompt = resolved_prompt_text(&settings).unwrap_or_default(); + let prompt_tokens = crate::ollama_chat::estimate_tokens(&prompt).saturating_add( + crate::ollama_chat::estimate_tokens(&"a".repeat(PREWARM_TRANSCRIPT_CHARS)), + ); + tauri::async_runtime::spawn(async move { + // Cold only: a model that is already loaded, at any context, is left + // alone. The request itself reuses or resizes it, so a warm-up never + // causes a reload that the dictation would not have needed. + crate::ollama_chat::warm_if_cold( + &provider.base_url, + &model, + prompt_tokens, + crate::ollama_chat::CallKind::Dictation.num_predict(), + ) + .await; + }); } pub(crate) async fn process_transcription_output( app: &AppHandle, transcription: &str, post_process: bool, + wait: CleanupWait, ) -> ProcessedTranscription { let settings = get_settings(app); let mut final_text = transcription.to_string(); let mut post_processed_text: Option = None; let mut post_process_prompt: Option = None; + let mut late_cleanup: Option = None; if let Some(converted_text) = maybe_convert_chinese_variant(&settings, transcription).await { final_text = converted_text; } if post_process { - if let Some(processed_text) = post_process_transcription(&settings, &final_text).await { - post_processed_text = Some(processed_text.clone()); - final_text = processed_text; - - if let Some(prompt_id) = &settings.post_process_selected_prompt_id { - if let Some(prompt) = settings - .post_process_prompts - .iter() - .find(|prompt| &prompt.id == prompt_id) - { - post_process_prompt = Some(prompt.prompt.clone()); - } + // D-010: the clean-up runs as its own task, so that giving up waiting + // at the cursor does not cancel it. Cancelling it made Ollama abort a + // half-finished model load (probe B, 23 Oct). + let wait = if wait == CleanupWait::AtCursor && !can_finish_late(&settings) { + CleanupWait::UntilDone + } else { + wait + }; + let prompt_text = resolved_prompt_text(&settings); + let task_settings = settings.clone(); + let task_text = final_text.clone(); + let mut handle = tauri::async_runtime::spawn(async move { + post_process_transcription(&task_settings, &task_text).await + }); + match await_cleanup(&mut handle, wait, CURSOR_WAIT).await { + Some(Some(processed_text)) => { + post_processed_text = Some(processed_text.clone()); + final_text = processed_text; + post_process_prompt = prompt_text; + } + Some(None) => {} + None => { + warn!( + "Clean-up not finished after {} s: pasting the words as spoken; \ + the clean-up carries on and goes to History", + CURSOR_WAIT.as_secs() + ); + late_cleanup = Some(LateCleanup { + handle, + prompt: prompt_text, + }); } } } else if final_text != transcription { @@ -435,6 +621,7 @@ pub(crate) async fn process_transcription_output( final_text, post_processed_text, post_process_prompt, + late_cleanup, } } @@ -520,6 +707,10 @@ impl ShortcutAction for TranscribeAction { if recording_error.is_none() { // Dynamically register the cancel shortcut in a separate task to avoid deadlock shortcut::register_cancel_shortcut(app); + // Kōrero 1.45.1 (D-010): load the clean-up model while they speak. + if self.post_process { + prewarm_cleanup_model(app); + } } else { // Starting failed (for example due to blocked microphone permissions). // Revert UI state so we don't stay stuck in the recording overlay. @@ -654,22 +845,39 @@ impl ShortcutAction for TranscribeAction { let prompt_name = resolve_prompt_name(&get_settings(&ah)); show_processing_overlay(&ah, prompt_name); } - let processed = - process_transcription_output(&ah, &transcription, post_process) - .await; + let mut processed = process_transcription_output( + &ah, + &transcription, + post_process, + CleanupWait::AtCursor, + ) + .await; // Save to history if WAV was saved + let mut entry_id: Option = None; + let spoken = transcription.clone(); if wav_saved { - if let Err(err) = hm.save_entry( + match hm.save_entry( file_name, transcription, post_process, processed.post_processed_text.clone(), processed.post_process_prompt.clone(), ) { - error!("Failed to save history entry: {}", err); + Ok(entry) => entry_id = Some(entry.id), + Err(err) => error!("Failed to save history entry: {}", err), } } + // D-010: a clean-up that outlasted the cursor wait + // finishes in the background and files itself on + // the History entry just saved. + // The pill mentions History only when there is an + // entry for the clean-up to land on (review, Minor 1). + let cleanup_late = + processed.late_cleanup.is_some() && entry_id.is_some(); + if let Some(late) = processed.late_cleanup.take() { + finish_late_cleanup(&ah, late, entry_id, spoken); + } if processed.final_text.is_empty() { utils::hide_recording_overlay(&ah); @@ -687,7 +895,18 @@ impl ShortcutAction for TranscribeAction { "Text pasted successfully in {:?}", paste_time.elapsed() ); - utils::hide_recording_overlay(&ah_clone); + if cleanup_late { + // D-010: say why the words went + // in as spoken, where the pill + // is being looked at. + utils::show_overlay_notice( + &ah_clone, + "notice-cleanup-late", + 6000, + ); + } else { + utils::hide_recording_overlay(&ah_clone); + } } Err(e) => { error!("Failed to paste transcription: {}", e); @@ -819,3 +1038,100 @@ pub static ACTION_MAP: Lazy>> = Lazy::ne ); map }); + +#[cfg(test)] +mod cleanup_wait_tests { + use super::*; + + fn slow_cleanup(after_ms: u64) -> tauri::async_runtime::JoinHandle> { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_millis(after_ms)).await; + Some("Cleaned.".to_string()) + }) + } + + /// A clean-up that answers inside the cursor wait is used as before. + #[test] + fn a_quick_clean_up_is_used_at_the_cursor() { + tauri::async_runtime::block_on(async { + let mut h = slow_cleanup(10); + let got = await_cleanup(&mut h, CleanupWait::AtCursor, Duration::from_secs(5)).await; + assert_eq!(got, Some(Some("Cleaned.".to_string()))); + }); + } + + /// D-010, the property the fix rests on: running out of the cursor wait + /// hands back control but does not cancel the clean-up, which still + /// finishes and can be filed in History (cancelling it made Ollama abort + /// its half-finished model load, probe B, 23 Oct 2026). + #[test] + fn giving_up_at_the_cursor_does_not_cancel_the_clean_up() { + tauri::async_runtime::block_on(async { + let mut h = slow_cleanup(300); + let started = Instant::now(); + let got = await_cleanup(&mut h, CleanupWait::AtCursor, Duration::from_millis(50)).await; + assert_eq!(got, None, "the cursor wait must run out first"); + assert!(started.elapsed() < Duration::from_millis(250)); + assert_eq!(h.await.ok().flatten(), Some("Cleaned.".to_string())); + }); + } + + /// History retry and Notes have nobody at a cursor: they wait it out. + #[test] + fn until_done_waits_past_the_cursor_limit() { + tauri::async_runtime::block_on(async { + let mut h = slow_cleanup(120); + let got = + await_cleanup(&mut h, CleanupWait::UntilDone, Duration::from_millis(10)).await; + assert_eq!(got, Some(Some("Cleaned.".to_string()))); + }); + } + + /// A failed clean-up is reported as finished-without-text, not as late. + #[test] + fn a_failed_clean_up_is_not_late() { + tauri::async_runtime::block_on(async { + let mut h = tauri::async_runtime::spawn(async { None:: }); + let got = await_cleanup(&mut h, CleanupWait::AtCursor, Duration::from_secs(5)).await; + assert_eq!(got, Some(None)); + }); + } + + /// Review M1: only a local Ollama clean-up may finish late; every other + /// provider has its own 30 s limit, so it is waited for as before. + #[test] + fn only_ollama_clean_up_may_finish_late() { + let mut s = crate::settings::get_default_settings(); + for (id, late) in [("ollama", true), ("custom", false), ("openai", false)] { + s.post_process_provider_id = id.to_string(); + assert_eq!(can_finish_late(&s), late, "{id}"); + } + } + + #[test] + fn the_prewarm_leaves_room_for_a_dictation() { + // Default clean-up prompt plus PREWARM_TRANSCRIPT_CHARS must land on a + // context that holds a 4,000-character dictation, so it is reused. + let s = crate::settings::get_default_settings(); + let template = s + .post_process_prompts + .iter() + .map(|p| p.prompt.chars().count()) + .max() + .unwrap_or(0); + let warm = crate::ollama_chat::estimate_tokens(&"a".repeat(template)) + + crate::ollama_chat::estimate_tokens(&"a".repeat(PREWARM_TRANSCRIPT_CHARS)); + let need = crate::ollama_chat::estimate_tokens(&"a".repeat(template + 4_000)); + let (warm_ctx, _) = + crate::ollama_chat::choose_num_ctx(warm, 1_500, None).expect("warm context"); + let (used_ctx, _) = + crate::ollama_chat::choose_num_ctx(need, 1_500, Some(warm_ctx)).expect("call"); + assert_eq!(used_ctx, warm_ctx, "the dictation would reload the model"); + } + + #[test] + fn the_cursor_wait_is_unchanged() { + assert_eq!(CURSOR_WAIT, Duration::from_secs(30)); + assert!(crate::ollama_chat::DICTATION_LIMIT > CURSOR_WAIT * 5); + } +} diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index daf628e1d9..8134a20ce9 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -93,8 +93,13 @@ pub async fn retry_history_entry_transcription( return Err("Recording contains no speech".to_string()); } - let processed = - process_transcription_output(&app, &transcription, entry.post_process_requested).await; + let processed = process_transcription_output( + &app, + &transcription, + entry.post_process_requested, + crate::actions::CleanupWait::UntilDone, + ) + .await; history_manager .update_transcription( id, diff --git a/src-tauri/src/commands/notes.rs b/src-tauri/src/commands/notes.rs index 67ae44d509..78836a25df 100644 --- a/src-tauri/src/commands/notes.rs +++ b/src-tauri/src/commands/notes.rs @@ -77,7 +77,15 @@ pub async fn note_stop_dictation( // Reuse the shared text pipeline (Chinese-variant conversion + optional // post-processing). No paste, no History write — just hand the text back. - let processed = crate::actions::process_transcription_output(&app, &raw, post_process).await; + // Kōrero 1.45.1 (D-010): nothing is pasted here, so wait for the clean-up + // rather than give up at the cursor limit. + let processed = crate::actions::process_transcription_output( + &app, + &raw, + post_process, + crate::actions::CleanupWait::UntilDone, + ) + .await; Ok(processed.final_text) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6215639d93..6deda33639 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,6 +57,9 @@ use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Emitter, Listener, Manager}; use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; use tauri_plugin_log::{Builder as LogBuilder, RotationStrategy, Target, TargetKind}; + +/// Kōrero 1.45.1: earlier log files kept beside the current one. +const LOG_FILES_KEPT: usize = 4; // Kōrero (2026-05-17 PM): persist window size + position across launches so // Nic doesn't have to resize away from the default every session. #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -728,7 +731,12 @@ pub fn run(cli_args: CliArgs) { LogBuilder::new() .level(log::LevelFilter::Trace) // Set to most verbose level globally .max_file_size(500_000) - .rotation_strategy(RotationStrategy::KeepOne) + // Kōrero 1.45.1: KeepOne deletes the whole log every time it + // reaches 500 KB, so a problem is often gone from the log by the + // time anyone looks (23 Oct 2026: the 13:47 clean-up failure was + // erased by a rotation at 13:51). Keep four earlier files, about + // 2.5 MB in all. Transcript text is never logged (SEC-143-06). + .rotation_strategy(RotationStrategy::KeepSome(LOG_FILES_KEPT)) .clear_targets() .targets([ // Console output respects RUST_LOG environment variable diff --git a/src-tauri/src/managers/history.rs b/src-tauri/src/managers/history.rs index 8d4889efe8..8a2f0002f6 100644 --- a/src-tauri/src/managers/history.rs +++ b/src-tauri/src/managers/history.rs @@ -649,6 +649,69 @@ impl HistoryManager { Ok(()) } + /// Kōrero 1.45.1 (D-010): file a clean-up that finished after its words were + /// pasted as spoken. Fills the entry only while it still has no clean-up, + /// so a correction or "Tidy te reo" made in the meantime is never + /// overwritten, and only while the entry still holds `spoken`, the words the + /// clean-up read (a Retry may have re-transcribed it). `Ok(None)` otherwise, + /// or when the entry was deleted. + pub fn set_late_post_process( + &self, + id: i64, + text: String, + prompt: Option, + spoken: &str, + ) -> Result> { + let conn = self.get_connection()?; + let entry = + Self::set_late_post_process_with_conn(&conn, id, &text, prompt.as_deref(), spoken)?; + if let Some(entry) = &entry { + if let Err(e) = (HistoryUpdatePayload::Updated { + entry: entry.clone(), + }) + .emit(&self.app_handle) + { + error!("Failed to emit history-updated event: {}", e); + } + } else { + debug!( + "Late clean-up for entry {} not filed: cleaned, edited, re-transcribed or gone", + id + ); + } + Ok(entry) + } + + fn set_late_post_process_with_conn( + conn: &Connection, + id: i64, + text: &str, + prompt: Option<&str>, + spoken: &str, + ) -> Result> { + // Blank means blank to the History page too, whose JavaScript trim() + // also drops tabs and line breaks (review, Minor 6). + let updated = conn.execute( + "UPDATE transcription_history + SET post_processed_text = ?1, post_process_prompt = ?2 + WHERE id = ?3 + AND transcription_text = ?4 + AND (post_processed_text IS NULL + OR trim(post_processed_text, ' ' || char(9) || char(10) || char(13)) = '')", + params![text, prompt, id, spoken], + )?; + if updated == 0 { + return Ok(None); + } + let entry = conn.query_row( + "SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt, post_process_requested + FROM transcription_history WHERE id = ?1", + params![id], + Self::map_history_entry, + )?; + Ok(Some(entry)) + } + /// Update only the post-processed text for a history entry. /// /// Korero (v1.4.0): used by the inline correction UI in the History panel. @@ -807,6 +870,73 @@ mod tests { .expect("insert history entry"); } + /// D-010: a late clean-up fills an entry that has none, keeps the words + /// as spoken, and never overwrites a clean-up or correction made since. + #[test] + fn late_clean_up_fills_only_an_empty_entry() { + let conn = setup_conn(); + insert_entry(&conn, 100, "as spoken", None); + insert_entry(&conn, 200, "edited", Some("my own correction")); + insert_entry(&conn, 300, "blank", Some(" \n\t ")); + + let filled = HistoryManager::set_late_post_process_with_conn( + &conn, + 1, + "Cleaned.", + Some("p"), + "as spoken", + ) + .expect("update") + .expect("filled"); + assert_eq!(filled.post_processed_text.as_deref(), Some("Cleaned.")); + assert_eq!(filled.post_process_prompt.as_deref(), Some("p")); + assert_eq!(filled.transcription_text, "as spoken"); + + let kept = + HistoryManager::set_late_post_process_with_conn(&conn, 2, "Cleaned.", None, "edited") + .expect("update"); + assert!( + kept.is_none(), + "a correction made in the meantime must survive" + ); + let text: String = conn + .query_row( + "SELECT post_processed_text FROM transcription_history WHERE id = 2", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(text, "my own correction"); + + assert!( + HistoryManager::set_late_post_process_with_conn(&conn, 3, "Cleaned.", None, "blank") + .expect("update") + .is_some(), + "whitespace-only counts as no clean-up" + ); + assert!( + HistoryManager::set_late_post_process_with_conn(&conn, 99, "x", None, "x") + .expect("missing entry is not an error") + .is_none() + ); + + // Review Minor 3: a Retry re-transcribed the entry while the late + // clean-up ran; the clean-up of the old words must not be filed on it. + insert_entry(&conn, 400, "new words", None); + assert!( + HistoryManager::set_late_post_process_with_conn( + &conn, + 4, + "Cleaned old words.", + None, + "old words", + ) + .expect("update") + .is_none(), + "a clean-up of other words must not be filed" + ); + } + #[test] fn get_latest_entry_returns_none_when_empty() { let conn = setup_conn(); diff --git a/src-tauri/src/ollama_chat.rs b/src-tauri/src/ollama_chat.rs index fb30d42e79..49dfed7e46 100644 --- a/src-tauri/src/ollama_chat.rs +++ b/src-tauri/src/ollama_chat.rs @@ -52,7 +52,26 @@ pub(crate) const SMALL_BODY: usize = 64 << 10; // /api/ps, /api/show const MAX_SANE_CTX: u64 = 262_144; const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); -const DICTATION_TIMEOUT: Duration = Duration::from_secs(30); +/// Kōrero 1.45.1 (D-010): how long a dictation clean-up request may run. +/// +/// Until 1.45.1 this was 30 s, and it also had to cover Ollama loading the +/// model. A cold load of gemma4:12b took 15-19 s on the test laptop most days +/// and **108 s** on 23 Oct 2026 with 3 GB of RAM free, so clean-up failed twice +/// that day at 30.1 s. Worse, Ollama aborts a load when the request waiting on +/// it disconnects (probe B, 23 Oct: "client connection closed before +/// llama-server finished loading, aborting load"), so every give-up also threw +/// the half-loaded model away and the next try started from nothing. +/// +/// The person at the text cursor no longer waits on this limit: +/// `actions::CURSOR_WAIT` decides when to paste the words as spoken, and this +/// request carries on in the background so the clean-up still lands in History +/// and the model is loaded for the next dictation. It only has to outlast the +/// slowest load seen plus the answer, and still end a request that is wedged. +pub const DICTATION_LIMIT: Duration = Duration::from_secs(300); +/// A pre-warm has to outlive the slowest model load: if it gives up first, +/// Ollama aborts the load it started (probe B). Was 120 s, under the 108 s +/// measured cold load plus GPU discovery. +const WARM_LIMIT: Duration = Duration::from_secs(300); const IDLE_TIMEOUT: Duration = Duration::from_secs(180); const TOTAL_CEILING: Duration = Duration::from_secs(3_600); /// A non-streamed reply arrives only when it is complete, so its limit must @@ -70,7 +89,9 @@ Use the trim markers to choose the part that matters, then try again."; /// Which caller is asking. Decides the output allowance and the time budget. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CallKind { - /// Dictation clean-up: the user is waiting at a text cursor. + /// Dictation clean-up. The person may be waiting at a text cursor, but + /// only for `actions::CURSOR_WAIT`; the request itself may run to + /// `DICTATION_LIMIT` and finish in the background (D-010). Dictation, /// Meeting notes (streamed). Meeting, @@ -92,7 +113,7 @@ impl CallKind { /// a long transcript needs more than a short dictation. fn budget(self, prompt_tokens: u32) -> Duration { match self { - CallKind::Dictation => DICTATION_TIMEOUT, + CallKind::Dictation => DICTATION_LIMIT, // Reading the prompt at PROMPT_FLOOR_TPS. Measured on the test laptop // 2026-10-02 with gemma4:12b: 1,414 tokens/s with the graphics card // to itself, 71 tokens/s while Kōrero's own speech model was also @@ -106,7 +127,7 @@ impl CallKind { /// plus writing the answer. fn total_budget(self, prompt_tokens: u32, num_predict: u32) -> Duration { match self { - CallKind::Dictation => DICTATION_TIMEOUT, + CallKind::Dictation => DICTATION_LIMIT, _ => (self.budget(prompt_tokens) + Duration::from_secs(u64::from(num_predict) / GEN_FLOOR_TPS)) .min(NON_STREAM_CEILING), @@ -684,6 +705,22 @@ pub async fn chat_stream( p.into_result().map(Outcome::Done) } +/// D-010: warm only a model that is not loaded at all. A dictation pre-warm +/// must never reload a model that is already resident at another context: +/// the request may fit it as it is, and a reload costs as much as a cold load. +pub async fn warm_if_cold(base_url: &str, model: &str, prompt_tokens: u32, num_predict: u32) { + if model.trim().is_empty() { + return; + } + if loaded_context(&native_base(base_url), model) + .await + .is_some() + { + return; + } + warm(base_url, model, prompt_tokens, num_predict).await; +} + /// Load a model ahead of use, at the context the coming call will want, so /// the call itself doesn't pay for a reload. Best-effort; never errors. pub async fn warm(base_url: &str, model: &str, prompt_tokens: u32, num_predict: u32) { @@ -707,7 +744,8 @@ pub async fn warm(base_url: &str, model: &str, prompt_tokens: u32, num_predict: "options": {"num_ctx": num_ctx} }); let Ok(client) = reqwest::Client::builder() - .timeout(Duration::from_secs(120)) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(WARM_LIMIT) .build() else { return; @@ -1102,6 +1140,61 @@ mod tests { }); } + /// D-010 live: from a cold model, a dictation clean-up that the cursor + /// stops waiting for still finishes, from one load. Before 1.45.1 the + /// request gave up at 30 s and Ollama aborted the load (23 Oct 2026). + #[test] + #[ignore = "live: needs a local Ollama with the model (the test laptop)"] + fn live_cold_dictation_survives_the_cursor_wait() { + tauri::async_runtime::block_on(async { + let provider = live_provider(); + let model = live_model(); + let base = native_base(&provider.base_url); + let _ = reqwest::Client::new() + .post(format!("{base}/api/generate")) + .json(&json!({"model": model, "keep_alive": 0})) + .send() + .await; + tokio::time::sleep(Duration::from_secs(3)).await; + assert_eq!( + loaded_context(&base, &model).await, + None, + "model still loaded" + ); + + let (p, m) = (provider.clone(), model.clone()); + let mut h = tauri::async_runtime::spawn(async move { + chat( + &p, + "", + &m, + Some("Reply with one short sentence."), + "um so the hui is on tuesday no wednesday at the marae", + CallKind::Dictation, + ) + .await + }); + let started = std::time::Instant::now(); + // A cursor wait far shorter than any cold load: it must run out + // without cancelling the request. + let early = tokio::time::timeout(Duration::from_secs(2), &mut h).await; + assert!(early.is_err(), "a cold load answered within 2 s"); + let answer = h.await.expect("task joined"); + println!( + "LIVE cold dictation answered {:.1}s after it was sent", + started.elapsed().as_secs_f32() + ); + match answer { + Ok(Outcome::Done(Some(text))) => assert!(!text.trim().is_empty()), + other => panic!("cold dictation clean-up failed: {other:?}"), + } + assert!( + loaded_context(&base, &model).await.is_some(), + "the model should stay loaded for the next dictation" + ); + }); + } + #[test] #[ignore = "live: needs a local Ollama with the model (the test laptop)"] fn live_no_reload_across_a_session() { @@ -1158,13 +1251,25 @@ mod tests { #[test] fn budgets() { - assert_eq!(CallKind::Dictation.budget(10_000), DICTATION_TIMEOUT); + assert_eq!(CallKind::Dictation.budget(10_000), DICTATION_LIMIT); assert!(CallKind::Ask.budget(18_000) > CallKind::Ask.budget(1_000)); assert!(CallKind::Meeting.budget(u32::MAX) <= Duration::from_secs(900)); assert_eq!(CallKind::Meeting.num_predict(), 8_192); assert_eq!(CallKind::Ask.num_predict(), 1_500); } + /// D-010: a dictation request must outlive the slowest cold load measured + /// (108 s on 23 Oct 2026), and so must a pre-warm, or Ollama aborts the + /// load it was waiting on. Both still end a wedged request. + #[test] + fn dictation_and_warm_outlive_a_slow_cold_load() { + let slowest_load_seen = Duration::from_secs(108); + assert!(CallKind::Dictation.total_budget(500, 1_500) >= slowest_load_seen * 2); + assert!(WARM_LIMIT >= slowest_load_seen * 2); + assert!(DICTATION_LIMIT <= Duration::from_secs(600)); + assert!(WARM_LIMIT <= Duration::from_secs(600)); + } + #[test] fn non_stream_budget_covers_the_answer() { // Live 2026-10-02: a 40k-character meeting fallback was cut at 260 s. @@ -1175,10 +1280,11 @@ mod tests { for kind in [CallKind::Ask, CallKind::Note, CallKind::Meeting] { assert!(kind.total_budget(1_000, 1_500) > kind.budget(1_000)); } - // Dictation stays short: someone is waiting at a text cursor. + // Dictation is bounded by DICTATION_LIMIT; the cursor wait is separate + // (actions::CURSOR_WAIT, D-010). assert_eq!( CallKind::Dictation.total_budget(500, 1_500), - DICTATION_TIMEOUT + DICTATION_LIMIT ); assert!(CallKind::Meeting.total_budget(u32::MAX, u32::MAX) <= NON_STREAM_CEILING); } diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 1f3ab6c3f8..36447b6d56 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -434,7 +434,7 @@ pub fn show_processing_overlay(app_handle: &AppHandle, prompt_name: Option