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) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:10:54 +01:00
parent ef42c95000
commit 489c066a70
4 changed files with 100 additions and 12 deletions

1
Cargo.lock generated
View File

@@ -2604,6 +2604,7 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-window-state",
"tempfile",
"tokio",
"uuid",
"webkit2gtk",

View File

@@ -97,7 +97,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, 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<Vec<TranscriptRow>> {
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<TranscriptRow> {
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<Vec<TranscriptRow>> {
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)

View File

@@ -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]

View File

@@ -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<TranscriptRow> for TranscriptDto {
@@ -68,6 +70,7 @@ impl From<TranscriptRow> 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<String>,
#[serde(default)]
pub segments_json: Option<String>,
/// 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<String>,
}
#[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())?;