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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2604,6 +2604,7 @@ dependencies = [
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
Reference in New Issue
Block a user