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

@@ -1,11 +1,14 @@
use sqlx::SqlitePool;
use kon_core::error::{KonError, Result};
use sqlx::SqlitePool;
/// Each migration is a (version, description, sql) tuple.
/// Migrations MUST be append-only — never modify an existing migration.
/// Column defaults and NOT NULL constraints must exactly match the existing schema.
const MIGRATIONS: &[(i64, &str, &str)] = &[
(1, "initial schema", r#"
(
1,
"initial schema",
r#"
CREATE TABLE IF NOT EXISTS transcripts (
id TEXT PRIMARY KEY,
text TEXT NOT NULL DEFAULT '',
@@ -72,8 +75,12 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
CREATE INDEX IF NOT EXISTS idx_tasks_bucket ON tasks(bucket);
CREATE INDEX IF NOT EXISTS idx_tasks_transcript ON tasks(source_transcript_id);
CREATE INDEX IF NOT EXISTS idx_error_log_context ON error_log(context)
"#),
(2, "transcripts FTS5 + dictionary table", r#"
"#,
),
(
2,
"transcripts FTS5 + dictionary table",
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS transcripts_fts USING fts5(
text,
title,
@@ -107,14 +114,23 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
);
CREATE INDEX IF NOT EXISTS idx_dictionary_term ON dictionary(term)
"#),
(3, "micro-stepping: parent_task_id on tasks", r#"
"#,
),
(
3,
"micro-stepping: parent_task_id on tasks",
r#"
ALTER TABLE tasks ADD COLUMN parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)
"#),
(4, "tasks_meta: notes column for persisted free-text", r#"
"#,
),
(
4,
"tasks_meta: notes column for persisted free-text",
r#"
ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT ''
"#),
"#,
),
(
5,
"transcripts_meta",
@@ -187,6 +203,18 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
DROP TABLE IF EXISTS dictionary;
"#,
),
(
8,
"transcript_profile_provenance",
r#"
ALTER TABLE transcripts
ADD COLUMN profile_id TEXT NOT NULL
DEFAULT '00000000-0000-0000-0000-000000000001';
CREATE INDEX IF NOT EXISTS idx_transcripts_profile_id
ON transcripts(profile_id);
"#,
),
];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -203,15 +231,21 @@ fn split_statements(sql: &str) -> Vec<String> {
let prev_alpha = i > 0 && ob[i - 1].is_ascii_alphanumeric();
if !prev_alpha && ub[i..].starts_with(b"BEGIN") {
let next_alpha = i + 5 < n && ob[i + 5].is_ascii_alphanumeric();
if !next_alpha { depth += 1; }
if !next_alpha {
depth += 1;
}
}
if !prev_alpha && ub[i..].starts_with(b"END") {
let next_alpha = i + 3 < n && ob[i + 3].is_ascii_alphanumeric();
if !next_alpha && depth > 0 { depth -= 1; }
if !next_alpha && depth > 0 {
depth -= 1;
}
}
if ob[i] == b';' && depth == 0 {
let stmt = current.trim().to_string();
if !stmt.is_empty() { result.push(stmt); }
if !stmt.is_empty() {
result.push(stmt);
}
current = String::new();
} else {
current.push(ob[i] as char);
@@ -219,7 +253,9 @@ fn split_statements(sql: &str) -> Vec<String> {
i += 1;
}
let stmt = current.trim().to_string();
if !stmt.is_empty() { result.push(stmt); }
if !stmt.is_empty() {
result.push(stmt);
}
result
}
@@ -230,7 +266,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
version INTEGER PRIMARY KEY,
description TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)",
)
.execute(pool)
.await
@@ -248,10 +284,9 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
let statements = split_statements(sql);
for statement in &statements {
sqlx::query(statement)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?;
sqlx::query(statement).execute(pool).await.map_err(|e| {
KonError::StorageError(format!("Migration {} failed: {e}", version))
})?;
}
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
@@ -259,7 +294,9 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
.bind(description)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Migration version record failed: {e}")))?;
.map_err(|e| {
KonError::StorageError(format!("Migration version record failed: {e}"))
})?;
log::info!("Migration {} complete", version);
}
@@ -287,7 +324,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 7);
assert_eq!(count, 8);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool)
@@ -309,7 +346,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 7);
assert_eq!(count, 8);
}
#[tokio::test]
@@ -328,10 +365,7 @@ mod tests {
.fetch_all(&pool)
.await
.unwrap();
let names: Vec<String> = info
.iter()
.map(|r| r.get::<String, _>("name"))
.collect();
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
for col in ["list_id", "effort", "notes"] {
assert!(
names.contains(&col.to_string()),
@@ -356,11 +390,14 @@ mod tests {
.fetch_all(&pool)
.await
.unwrap();
let names: Vec<String> = info
.iter()
.map(|r| r.get::<String, _>("name"))
.collect();
for col in ["starred", "manual_tags", "template", "language", "segments_json"] {
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
for col in [
"starred",
"manual_tags",
"template",
"language",
"segments_json",
] {
assert!(
names.contains(&col.to_string()),
"transcripts must have {col}; got {names:?}"
@@ -368,6 +405,26 @@ mod tests {
}
}
#[tokio::test]
async fn migration_transcript_profile_provenance_adds_profile_id() {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("pool");
run_migrations(&pool).await.expect("migrate");
let info = sqlx::query("PRAGMA table_info(transcripts)")
.fetch_all(&pool)
.await
.unwrap();
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
assert!(
names.contains(&"profile_id".to_string()),
"transcripts must have profile_id; got {names:?}"
);
}
#[tokio::test]
async fn test_parent_task_id_cascade_delete() {
let pool = SqlitePoolOptions::new()
@@ -399,7 +456,10 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining, 0, "cascade delete should remove child when parent is deleted");
assert_eq!(
remaining, 0,
"cascade delete should remove child when parent is deleted"
);
}
/// Test-only helper: run migrations only up to (and including) `target_version`.
@@ -411,32 +471,36 @@ mod tests {
version INTEGER PRIMARY KEY,
description TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)",
)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Schema version table creation failed: {e}")))?;
.map_err(|e| {
KonError::StorageError(format!("Schema version table creation failed: {e}"))
})?;
let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(pool)
.await
.map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?;
let current: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(pool)
.await
.map_err(|e| KonError::StorageError(format!("Schema version query failed: {e}")))?;
for (version, description, sql) in MIGRATIONS {
if *version > current && *version <= target_version {
let statements = split_statements(sql);
for statement in &statements {
sqlx::query(statement)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Migration {} failed: {e}", version)))?;
sqlx::query(statement).execute(pool).await.map_err(|e| {
KonError::StorageError(format!("Migration {} failed: {e}", version))
})?;
}
sqlx::query("INSERT INTO schema_version (version, description) VALUES (?, ?)")
.bind(version)
.bind(description)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Migration version record failed: {e}")))?;
.map_err(|e| {
KonError::StorageError(format!("Migration version record failed: {e}"))
})?;
}
}
Ok(())
@@ -452,16 +516,22 @@ mod tests {
run_migrations(&pool).await.expect("migrate");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles")
.fetch_one(&pool).await.unwrap();
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 1, "Default profile must be seeded on fresh install");
let name: String = sqlx::query_scalar("SELECT name FROM profiles WHERE id = ?")
.bind(crate::DEFAULT_PROFILE_ID)
.fetch_one(&pool).await.unwrap();
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(name, "Default");
let terms_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms")
.fetch_one(&pool).await.unwrap();
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(terms_count, 0, "Fresh DB has no dictionary rows to copy");
}
@@ -481,19 +551,27 @@ mod tests {
"INSERT INTO dictionary (term, note, created_at) VALUES \
('Kon', '', datetime('now')), \
('CORBEL', 'brand', datetime('now')), \
('Wren', '', datetime('now'))"
).execute(&pool).await.expect("seed dictionary");
('Wren', '', datetime('now'))",
)
.execute(&pool)
.await
.expect("seed dictionary");
run_migrations(&pool).await.expect("migrate to v6");
let copied: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?"
).bind(crate::DEFAULT_PROFILE_ID).fetch_one(&pool).await.unwrap();
let copied: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?")
.bind(crate::DEFAULT_PROFILE_ID)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(copied, 3);
let corbel_note: String = sqlx::query_scalar(
"SELECT note FROM profile_terms WHERE term = 'CORBEL'"
).fetch_one(&pool).await.unwrap();
let corbel_note: String =
sqlx::query_scalar("SELECT note FROM profile_terms WHERE term = 'CORBEL'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(corbel_note, "brand");
}
@@ -508,11 +586,15 @@ mod tests {
let result = sqlx::query("DELETE FROM profiles WHERE id = ?")
.bind(crate::DEFAULT_PROFILE_ID)
.execute(&pool).await;
.execute(&pool)
.await;
assert!(result.is_err(), "trigger must block Default deletion");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("Default profile cannot be deleted"), "got: {msg}");
assert!(
msg.contains("Default profile cannot be deleted"),
"got: {msg}"
);
}
#[tokio::test]
@@ -526,7 +608,8 @@ mod tests {
let result = sqlx::query("UPDATE profiles SET name = 'NotDefault' WHERE id = ?")
.bind(crate::DEFAULT_PROFILE_ID)
.execute(&pool).await;
.execute(&pool)
.await;
assert!(result.is_err(), "trigger must block Default rename");
}