From 489c066a705f84fb99376633a5cb5b6ed5327d7e Mon Sep 17 00:00:00 2001 From: Jake Date: Sat, 25 Apr 2026 00:10:54 +0100 Subject: [PATCH] feat(phase9): migration v14 + storage and Tauri command extension for llm_tags Adds llm_tags TEXT NOT NULL DEFAULT '' to the transcripts table via new migration v14. SELECT statements + TranscriptRow + transcript_row_from now carry the column. update_transcript_meta gains a sixth Option for llm_tags following the existing COALESCE pattern; an #[allow(too_many_arguments)] keeps clippy happy without inverting the signature into a struct that would just shift the indirection. The Tauri-side TranscriptDto + UpdateTranscriptMetaRequest + the update_transcript_meta_cmd command pass llm_tags through unchanged. Pre-existing manualTags persistence path now has a sibling for llmTags ready for the frontend to call. Phase 8 brittle test fix included: list_recent_completions_uses_local_day_boundary was anchoring its "-2 days" UTC offset against the local-day spine, which drifted across UTC midnight. Anchored to the local date directly so it matches the spine regardless of clock. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/storage/src/database.rs | 86 +++++++++++++++++++++++---- crates/storage/src/migrations.rs | 16 ++++- src-tauri/src/commands/transcripts.rs | 9 +++ 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e84add5..368d51c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2604,6 +2604,7 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-window-state", + "tempfile", "tokio", "uuid", "webkit2gtk", diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 2611114..59b1099 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -97,7 +97,7 @@ pub async fn insert_transcript( pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result> { let row = sqlx::query( - "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 = ?", + "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, llm_tags FROM transcripts WHERE id = ?", ) .bind(id) .fetch_optional(pool) @@ -119,7 +119,7 @@ pub async fn list_transcripts_paged( offset: i64, ) -> Result> { let rows = sqlx::query( - "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 ?", + "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, llm_tags FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?", ) .bind(limit) .bind(offset) @@ -198,6 +198,7 @@ pub async fn update_transcript( /// existing command surface stays stable — the viewer and history store /// keep calling `update_transcript` for content edits, and call this for /// UI-metadata persistence. +#[allow(clippy::too_many_arguments)] // Each Option maps to one COALESCE. pub async fn update_transcript_meta( pool: &SqlitePool, id: &str, @@ -206,6 +207,7 @@ pub async fn update_transcript_meta( template: Option<&str>, language: Option<&str>, segments_json: Option<&str>, + llm_tags: Option<&str>, ) -> Result { sqlx::query( "UPDATE transcripts SET \ @@ -213,7 +215,8 @@ pub async fn update_transcript_meta( manual_tags = COALESCE(?, manual_tags), \ template = COALESCE(?, template), \ language = COALESCE(?, language), \ - segments_json = COALESCE(?, segments_json) \ + segments_json = COALESCE(?, segments_json), \ + llm_tags = COALESCE(?, llm_tags) \ WHERE id = ?", ) .bind(starred.map(|b| b as i64)) @@ -221,6 +224,7 @@ pub async fn update_transcript_meta( .bind(template) .bind(language) .bind(segments_json) + .bind(llm_tags) .bind(id) .execute(pool) .await @@ -250,7 +254,7 @@ pub async fn search_transcripts( limit: i64, ) -> Result> { let rows = sqlx::query( - "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 \ + "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, t.llm_tags \ FROM transcripts t \ JOIN transcripts_fts fts ON fts.rowid = t.rowid \ WHERE transcripts_fts MATCH ? \ @@ -792,6 +796,10 @@ pub struct TranscriptRow { pub template: String, pub language: String, pub segments_json: String, + /// Phase 9 — comma-joined LLM-generated content tags ("topic:foo", + /// "intent:planning"). Migration v14. Empty string is the normal + /// no-tags state. + pub llm_tags: String, } #[derive(Debug, Clone)] @@ -851,6 +859,7 @@ fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow { template: r.get("template"), language: r.get("language"), segments_json: r.get("segments_json"), + llm_tags: r.get("llm_tags"), } } @@ -1613,7 +1622,7 @@ mod tests { .await .unwrap(); - let row = update_transcript_meta(&pool, "tm1", Some(true), None, None, None, None) + let row = update_transcript_meta(&pool, "tm1", Some(true), None, None, None, None, None) .await .unwrap(); assert!(row.starred, "starred must flip true after update"); @@ -1624,6 +1633,7 @@ mod tests { assert_eq!(fetched.template, ""); assert_eq!(fetched.language, ""); assert_eq!(fetched.segments_json, ""); + assert_eq!(fetched.llm_tags, "", "llm_tags defaults to empty"); } #[tokio::test] @@ -1664,6 +1674,7 @@ mod tests { Some("Meeting"), Some("en-GB"), None, + None, ) .await .unwrap(); @@ -1677,6 +1688,7 @@ mod tests { None, None, Some(r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#), + None, ) .await .unwrap(); @@ -1691,6 +1703,56 @@ mod tests { ); } + #[tokio::test] + async fn update_transcript_meta_writes_llm_tags() { + // Phase 9 — llm_tags writes via update_transcript_meta and round- + // trips via SELECT. Migration v14 default is empty string. + let pool = test_pool().await; + insert_transcript( + &pool, + &InsertTranscriptParams { + id: "tm-llm", + text: "body", + source: "microphone", + profile_id: crate::DEFAULT_PROFILE_ID, + title: None, + audio_path: None, + duration: 0.0, + engine: None, + model_id: None, + inference_ms: None, + sample_rate: None, + audio_channels: None, + format_mode: None, + remove_fillers: false, + british_english: false, + anti_hallucination: false, + }, + ) + .await + .unwrap(); + + let initial = get_transcript(&pool, "tm-llm").await.unwrap().unwrap(); + assert_eq!(initial.llm_tags, "", "default after insert"); + + update_transcript_meta( + &pool, + "tm-llm", + None, + None, + None, + None, + None, + Some("topic:grant-application,intent:planning"), + ) + .await + .unwrap(); + + let fetched = get_transcript(&pool, "tm-llm").await.unwrap().unwrap(); + assert_eq!(fetched.llm_tags, "topic:grant-application,intent:planning",); + assert_eq!(fetched.manual_tags, "", "manual_tags untouched"); + } + #[tokio::test] async fn update_task_overwrites_provided_fields() { // Task 2.6 — happy path: insert, update bucket + effort, read back. @@ -2287,13 +2349,17 @@ mod tests { .await .unwrap(); - // Put row "a" at a done_at that is definitely "2 days ago" locally. - // Anchor to start of day then add 12 h so the timestamp is noon UTC - // 2 days ago, regardless of when in the day the test runs. - // Row "b" uses the current moment (today). + // Put row "a" at a done_at that is definitely "2 days ago" in + // the same local-day spine the query uses. Anchor to the local + // date 2 days ago, then suffix " 12:00:00" so the bucket survives + // timezone shifts at the query time (noon is far enough from + // either day boundary). Phase 8's earlier "-2 days, start of + // day, +12 hours" form mixed UTC `now` with the local-time spine + // and drifted to "3 days ago" near UTC midnight; this anchor + // matches the spine directly. Row "b" stays at the current moment. sqlx::query( "UPDATE tasks SET done = 1, \ - done_at = datetime('now', '-2 days', 'start of day', '+12 hours'), \ + done_at = datetime(DATE('now', 'localtime', '-2 days') || ' 12:00:00'), \ auto_completed = 0 WHERE id = 'a'", ) .execute(&pool) diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index 7845e9c..aa286d9 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -451,6 +451,18 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ WHERE done_at IS NOT NULL; "#, ), + ( + 14, + "transcripts: llm_tags column for Phase 9 LLM content tags", + r#" + -- Phase 9 of the feature-complete roadmap. AI-generated content + -- tags (topic:* and intent:*) are stored alongside manual_tags as + -- a comma-joined string, mirroring how manual_tags persists. Pre- + -- existing rows default to empty string. The frontend chips loop + -- handles "" as "no tags". + ALTER TABLE transcripts ADD COLUMN llm_tags TEXT NOT NULL DEFAULT ''; + "#, + ), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -600,7 +612,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 13); + assert_eq!(count, 14); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -619,7 +631,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 13); + assert_eq!(count, 14); } #[tokio::test] diff --git a/src-tauri/src/commands/transcripts.rs b/src-tauri/src/commands/transcripts.rs index 3743e8b..c712125 100644 --- a/src-tauri/src/commands/transcripts.rs +++ b/src-tauri/src/commands/transcripts.rs @@ -48,6 +48,8 @@ pub struct TranscriptDto { pub template: String, pub language: String, pub segments_json: String, + /// Phase 9 LLM-generated content tags ("topic:...", "intent:..."). + pub llm_tags: String, } impl From for TranscriptDto { @@ -68,6 +70,7 @@ impl From for TranscriptDto { template: r.template, language: r.language, segments_json: r.segments_json, + llm_tags: r.llm_tags, } } } @@ -221,6 +224,11 @@ pub struct UpdateTranscriptMetaRequest { pub language: Option, #[serde(default)] pub segments_json: Option, + /// Phase 9 LLM content tags. Same comma-joined string convention as + /// `manual_tags`. Pass `None` to leave unchanged; pass `Some("")` to + /// explicitly clear. + #[serde(default)] + pub llm_tags: Option, } #[tauri::command] @@ -237,6 +245,7 @@ pub async fn update_transcript_meta_cmd( patch.template.as_deref(), patch.language.as_deref(), patch.segments_json.as_deref(), + patch.llm_tags.as_deref(), ) .await .map_err(|e| e.to_string())?;