feat: OpenWhispr-inspired transcription polish pass
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

Major quality pass on top of Phase 2. Five substantive changes plus
cross-cutting touches across audio, hotkey, transcription, and Tauri
command layers.

  Transcription quality

  - Long-audio chunking in commands/transcription.rs: Parakeet and large
    file transcription now chunk-and-recompose with overlap trimming, so
    the live-path chunking advantage extends to file-based workflows.
  - Stateful live speech gate in commands/live.rs on top of the earlier
    duplicate-boundary filtering — distinguishes start-of-speech from
    mid-speech and holds state across chunks.

  Auto-learning corrections

  - New crates/ai-formatting/src/correction_learning.rs: extracts user
    text corrections from viewer edits and proposes additions to the
    active profile's vocabulary.
  - src-tauri/src/commands/profiles.rs bridge for frontend-driven
    confirmation of learned terms.
  - src/routes/viewer/+page.svelte hooks the learning path into the
    segment-edit flow so corrections feed profile_terms without a
    separate 'train this profile' UX.

  Transcript profile provenance

  - Migration v8 (crates/storage/src/migrations.rs) adds profile_id to
    transcripts, defaulting to DEFAULT_PROFILE_ID so existing rows stay
    valid.
  - crates/storage/src/database.rs: TranscriptRow + CRUD carry profile_id.
  - src-tauri/src/commands/transcripts.rs: add_transcript accepts and
    persists profile_id.
  - DictationPage.svelte + FilesPage.svelte send activeProfileId on
    capture so learned corrections are attributed to the right profile.

  Cleanup prompt contract

  - crates/ai-formatting/src/llm_client.rs hardened: the CLEANUP_PROMPT
    now specifies concrete do/do-not rules, ready for a real model-backed
    cleanup pass. The llm_client is still a stub — kon-llm remains unwired
    — but the prompt shape is final.

  Cross-cutting polish

  - Minor touches in audio (capture/decode/resample), hotkey (lib/linux/stub),
    core, transcription (concurrency/model_manager/local_engine/whisper_rs),
    and the rest of src-tauri/src/commands/*: error-path tightening, log
    clarity, TS-migration follow-ups (@ts-nocheck additions for incremental
    typing).

Verified locally: npm run check, cargo test -p kon-ai-formatting,
cargo test -p kon-storage, cargo test -p kon --lib commands::live::tests,
cargo check — all green.

Scope boundary: kon-llm crate is still a stub; task extraction remains
rule-based. Bundled local-LLM runtime is the next clean step and is not
in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 22:39:08 +01:00
parent 28acdcfa6d
commit 34fce3cf9e
39 changed files with 1581 additions and 554 deletions

View File

@@ -43,6 +43,7 @@ pub struct InsertTranscriptParams<'a> {
pub id: &'a str,
pub text: &'a str,
pub source: &'a str,
pub profile_id: &'a str,
pub title: Option<&'a str>,
pub audio_path: Option<&'a str>,
pub duration: f64,
@@ -62,12 +63,13 @@ pub async fn insert_transcript(
params: &InsertTranscriptParams<'_>,
) -> Result<()> {
sqlx::query(
"INSERT INTO transcripts (id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO transcripts (id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(params.id)
.bind(params.text)
.bind(params.source)
.bind(params.profile_id)
.bind(params.title)
.bind(params.audio_path)
.bind(params.duration)
@@ -88,7 +90,7 @@ pub async fn insert_transcript(
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
let row = sqlx::query(
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts WHERE id = ?",
"SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
@@ -110,7 +112,7 @@ pub async fn list_transcripts_paged(
offset: i64,
) -> Result<Vec<TranscriptRow>> {
let rows = sqlx::query(
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
"SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
)
.bind(limit)
.bind(offset)
@@ -217,9 +219,9 @@ pub async fn update_transcript_meta(
.await
.map_err(|e| KonError::StorageError(format!("update_transcript_meta: {e}")))?;
get_transcript(pool, id)
.await?
.ok_or_else(|| KonError::StorageError(format!("update_transcript_meta: transcript {id} not found")))
get_transcript(pool, id).await?.ok_or_else(|| {
KonError::StorageError(format!("update_transcript_meta: transcript {id} not found"))
})
}
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
@@ -241,7 +243,7 @@ pub async fn search_transcripts(
limit: i64,
) -> Result<Vec<TranscriptRow>> {
let rows = sqlx::query(
"SELECT t.id, t.text, t.source, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at, t.starred, t.manual_tags, t.template, t.language, t.segments_json \
"SELECT t.id, t.text, t.source, t.profile_id, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at, t.starred, t.manual_tags, t.template, t.language, t.segments_json \
FROM transcripts t \
JOIN transcripts_fts fts ON fts.rowid = t.rowid \
WHERE transcripts_fts MATCH ? \
@@ -296,10 +298,7 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
.await
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
Ok(rows
.into_iter()
.map(task_row_from)
.collect())
Ok(rows.into_iter().map(task_row_from).collect())
}
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
@@ -350,9 +349,9 @@ pub async fn update_task(
.await
.map_err(|e| KonError::StorageError(format!("Update task failed: {e}")))?;
get_task_by_id(pool, id)
.await?
.ok_or_else(|| KonError::StorageError(format!("update_task: task {id} not found after update")))
get_task_by_id(pool, id).await?.ok_or_else(|| {
KonError::StorageError(format!("update_task: task {id} not found after update"))
})
}
pub async fn insert_subtask(
@@ -361,15 +360,13 @@ pub async fn insert_subtask(
text: &str,
parent_task_id: &str,
) -> Result<()> {
sqlx::query(
"INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)"
)
.bind(id)
.bind(text)
.bind(parent_task_id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert subtask failed: {e}")))?;
sqlx::query("INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)")
.bind(id)
.bind(text)
.bind(parent_task_id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert subtask failed: {e}")))?;
Ok(())
}
@@ -390,7 +387,9 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
/// Mark a subtask done. If all siblings are now done, auto-complete the parent.
/// Runs in a transaction so concurrent completions see consistent sibling counts.
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
let mut tx = pool.begin().await
let mut tx = pool
.begin()
.await
.map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?;
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
@@ -399,22 +398,22 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
.await
.map_err(|e| KonError::StorageError(format!("Complete subtask failed: {e}")))?;
let parent_id: Option<String> = sqlx::query_scalar(
"SELECT parent_task_id FROM tasks WHERE id = ?"
)
.bind(subtask_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?;
let parent_id: Option<String> =
sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
.bind(subtask_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?;
if let Some(pid) = parent_id {
let pending: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0"
)
.bind(&pid)
.fetch_one(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Count pending subtasks failed: {e}")))?;
let pending: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0")
.bind(&pid)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
KonError::StorageError(format!("Count pending subtasks failed: {e}"))
})?;
if pending == 0 {
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
@@ -425,7 +424,8 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
}
}
tx.commit().await
tx.commit()
.await
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
Ok(())
@@ -503,6 +503,7 @@ pub struct TranscriptRow {
pub id: String,
pub text: String,
pub source: String,
pub profile_id: String,
pub title: Option<String>,
pub audio_path: Option<String>,
pub duration: f64,
@@ -545,6 +546,7 @@ fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
id: r.get("id"),
text: r.get("text"),
source: r.get("source"),
profile_id: r.get("profile_id"),
title: r.get("title"),
audio_path: r.get("audio_path"),
duration: r.get("duration"),
@@ -619,23 +621,20 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
// instead of an opaque `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.
pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> {
let rows = sqlx::query(
"SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC",
)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List profiles failed: {e}")))?;
let rows =
sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC")
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List profiles failed: {e}")))?;
Ok(rows.iter().map(profile_row_from).collect())
}
pub async fn get_profile(pool: &SqlitePool, id: &str) -> Result<Option<ProfileRow>> {
let row = sqlx::query(
"SELECT id, name, initial_prompt, created_at FROM profiles WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Get profile failed: {e}")))?;
let row = sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Get profile failed: {e}")))?;
Ok(row.as_ref().map(profile_row_from))
}
@@ -852,6 +851,7 @@ mod tests {
id: "t1",
text: "Hello world",
source: "microphone",
profile_id: crate::DEFAULT_PROFILE_ID,
title: Some("Test"),
audio_path: None,
duration: 1.5,
@@ -872,6 +872,7 @@ mod tests {
let t = get_transcript(&pool, "t1").await.unwrap().unwrap();
assert_eq!(t.text, "Hello world");
assert_eq!(t.source, "microphone");
assert_eq!(t.profile_id, crate::DEFAULT_PROFILE_ID);
assert_eq!(t.duration, 1.5);
assert_eq!(t.engine.as_deref(), Some("whisper"));
assert_eq!(t.model_id.as_deref(), Some("whisper-tiny-en"));
@@ -912,9 +913,15 @@ mod tests {
#[tokio::test]
async fn subtask_crud_roundtrip() {
let pool = test_pool().await;
insert_task(&pool, "p1", "Write report", "inbox", None, None, None).await.unwrap();
insert_subtask(&pool, "s1", "Open document", "p1").await.unwrap();
insert_subtask(&pool, "s2", "Write introduction", "p1").await.unwrap();
insert_task(&pool, "p1", "Write report", "inbox", None, None, None)
.await
.unwrap();
insert_subtask(&pool, "s1", "Open document", "p1")
.await
.unwrap();
insert_subtask(&pool, "s2", "Write introduction", "p1")
.await
.unwrap();
// list_tasks must exclude subtasks
let top_level = list_tasks(&pool).await.unwrap();
@@ -926,14 +933,21 @@ mod tests {
assert_eq!(subs.len(), 2);
// completing s1 should NOT auto-complete parent (s2 still pending)
complete_subtask_and_check_parent(&pool, "s1").await.unwrap();
complete_subtask_and_check_parent(&pool, "s1")
.await
.unwrap();
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
assert!(!parent.done, "parent should not be done yet");
// completing s2 SHOULD auto-complete parent
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
complete_subtask_and_check_parent(&pool, "s2")
.await
.unwrap();
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
assert!(parent.done, "parent should auto-complete when all children done");
assert!(
parent.done,
"parent should auto-complete when all children done"
);
}
#[tokio::test]
@@ -946,6 +960,7 @@ mod tests {
id: "tm1",
text: "body",
source: "microphone",
profile_id: crate::DEFAULT_PROFILE_ID,
title: Some("Meta happy"),
audio_path: None,
duration: 0.0,
@@ -987,6 +1002,7 @@ mod tests {
id: "tm2",
text: "body",
source: "microphone",
profile_id: crate::DEFAULT_PROFILE_ID,
title: None,
audio_path: None,
duration: 0.0,
@@ -1034,7 +1050,10 @@ mod tests {
assert_eq!(row.manual_tags, "urgent,review");
assert_eq!(row.template, "Meeting");
assert_eq!(row.language, "en-GB");
assert_eq!(row.segments_json, r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#);
assert_eq!(
row.segments_json,
r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#
);
}
#[tokio::test]
@@ -1045,17 +1064,9 @@ mod tests {
.await
.unwrap();
let row = update_task(
&pool,
"u1",
None,
Some("today"),
None,
Some("15m"),
None,
)
.await
.unwrap();
let row = update_task(&pool, "u1", None, Some("today"), None, Some("15m"), None)
.await
.unwrap();
assert_eq!(row.bucket, "today");
assert_eq!(row.effort.as_deref(), Some("15m"));
assert_eq!(row.text, "Draft post", "text must be unchanged");
@@ -1070,9 +1081,17 @@ mod tests {
.await
.unwrap();
let row = update_task(&pool, "u2", None, None, None, None, Some("remember venue wifi"))
.await
.unwrap();
let row = update_task(
&pool,
"u2",
None,
None,
None,
None,
Some("remember venue wifi"),
)
.await
.unwrap();
assert_eq!(row.text, "Prep slides");
assert_eq!(row.bucket, "today");
assert_eq!(row.effort.as_deref(), Some("30m"));
@@ -1083,7 +1102,10 @@ mod tests {
async fn update_task_missing_id_errors() {
let pool = test_pool().await;
let res = update_task(&pool, "missing", Some("x"), None, None, None, None).await;
assert!(res.is_err(), "update_task must error when id does not exist");
assert!(
res.is_err(),
"update_task must error when id does not exist"
);
}
// --- Profile CRUD tests (Task 11) ---
@@ -1201,7 +1223,9 @@ mod tests {
add_profile_term(&pool, &p.id, "CORBEL", "company name")
.await
.unwrap();
add_profile_term(&pool, &p.id, "Wren", "agent name").await.unwrap();
add_profile_term(&pool, &p.id, "Wren", "agent name")
.await
.unwrap();
let terms = list_profile_terms(&pool, &p.id).await.unwrap();
assert_eq!(terms.len(), 2);
// Sorted alphabetically.
@@ -1229,7 +1253,9 @@ mod tests {
async fn add_profile_term_happy_path() {
let pool = test_pool().await;
let p = create_profile(&pool, "P", "").await.unwrap();
let row = add_profile_term(&pool, &p.id, "ACME", "client").await.unwrap();
let row = add_profile_term(&pool, &p.id, "ACME", "client")
.await
.unwrap();
assert_eq!(row.term, "ACME");
assert_eq!(row.note, "client");
assert_eq!(row.profile_id, p.id);
@@ -1293,11 +1319,12 @@ mod tests {
// Using a raw query because list_profile_terms would happily
// return [] even if the FK had been forgotten. We want to prove
// the child rows are actually gone from the table.
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?")
.bind(&p.id)
.fetch_one(&pool)
.await
.unwrap();
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?")
.bind(&p.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 0, "terms must cascade-delete with their profile");
}