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-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ pub async fn insert_transcript(
|
|||||||
|
|
||||||
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
|
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
|
||||||
let row = sqlx::query(
|
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)
|
.bind(id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -119,7 +119,7 @@ pub async fn list_transcripts_paged(
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<TranscriptRow>> {
|
) -> Result<Vec<TranscriptRow>> {
|
||||||
let rows = sqlx::query(
|
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(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
@@ -198,6 +198,7 @@ pub async fn update_transcript(
|
|||||||
/// existing command surface stays stable — the viewer and history store
|
/// existing command surface stays stable — the viewer and history store
|
||||||
/// keep calling `update_transcript` for content edits, and call this for
|
/// keep calling `update_transcript` for content edits, and call this for
|
||||||
/// UI-metadata persistence.
|
/// UI-metadata persistence.
|
||||||
|
#[allow(clippy::too_many_arguments)] // Each Option maps to one COALESCE.
|
||||||
pub async fn update_transcript_meta(
|
pub async fn update_transcript_meta(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -206,6 +207,7 @@ pub async fn update_transcript_meta(
|
|||||||
template: Option<&str>,
|
template: Option<&str>,
|
||||||
language: Option<&str>,
|
language: Option<&str>,
|
||||||
segments_json: Option<&str>,
|
segments_json: Option<&str>,
|
||||||
|
llm_tags: Option<&str>,
|
||||||
) -> Result<TranscriptRow> {
|
) -> Result<TranscriptRow> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE transcripts SET \
|
"UPDATE transcripts SET \
|
||||||
@@ -213,7 +215,8 @@ pub async fn update_transcript_meta(
|
|||||||
manual_tags = COALESCE(?, manual_tags), \
|
manual_tags = COALESCE(?, manual_tags), \
|
||||||
template = COALESCE(?, template), \
|
template = COALESCE(?, template), \
|
||||||
language = COALESCE(?, language), \
|
language = COALESCE(?, language), \
|
||||||
segments_json = COALESCE(?, segments_json) \
|
segments_json = COALESCE(?, segments_json), \
|
||||||
|
llm_tags = COALESCE(?, llm_tags) \
|
||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(starred.map(|b| b as i64))
|
.bind(starred.map(|b| b as i64))
|
||||||
@@ -221,6 +224,7 @@ pub async fn update_transcript_meta(
|
|||||||
.bind(template)
|
.bind(template)
|
||||||
.bind(language)
|
.bind(language)
|
||||||
.bind(segments_json)
|
.bind(segments_json)
|
||||||
|
.bind(llm_tags)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await
|
.await
|
||||||
@@ -250,7 +254,7 @@ pub async fn search_transcripts(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<TranscriptRow>> {
|
) -> Result<Vec<TranscriptRow>> {
|
||||||
let rows = sqlx::query(
|
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 \
|
FROM transcripts t \
|
||||||
JOIN transcripts_fts fts ON fts.rowid = t.rowid \
|
JOIN transcripts_fts fts ON fts.rowid = t.rowid \
|
||||||
WHERE transcripts_fts MATCH ? \
|
WHERE transcripts_fts MATCH ? \
|
||||||
@@ -792,6 +796,10 @@ pub struct TranscriptRow {
|
|||||||
pub template: String,
|
pub template: String,
|
||||||
pub language: String,
|
pub language: String,
|
||||||
pub segments_json: 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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -851,6 +859,7 @@ fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
|||||||
template: r.get("template"),
|
template: r.get("template"),
|
||||||
language: r.get("language"),
|
language: r.get("language"),
|
||||||
segments_json: r.get("segments_json"),
|
segments_json: r.get("segments_json"),
|
||||||
|
llm_tags: r.get("llm_tags"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1613,7 +1622,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(row.starred, "starred must flip true after update");
|
assert!(row.starred, "starred must flip true after update");
|
||||||
@@ -1624,6 +1633,7 @@ mod tests {
|
|||||||
assert_eq!(fetched.template, "");
|
assert_eq!(fetched.template, "");
|
||||||
assert_eq!(fetched.language, "");
|
assert_eq!(fetched.language, "");
|
||||||
assert_eq!(fetched.segments_json, "");
|
assert_eq!(fetched.segments_json, "");
|
||||||
|
assert_eq!(fetched.llm_tags, "", "llm_tags defaults to empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1664,6 +1674,7 @@ mod tests {
|
|||||||
Some("Meeting"),
|
Some("Meeting"),
|
||||||
Some("en-GB"),
|
Some("en-GB"),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1677,6 +1688,7 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#),
|
Some(r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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]
|
#[tokio::test]
|
||||||
async fn update_task_overwrites_provided_fields() {
|
async fn update_task_overwrites_provided_fields() {
|
||||||
// Task 2.6 — happy path: insert, update bucket + effort, read back.
|
// Task 2.6 — happy path: insert, update bucket + effort, read back.
|
||||||
@@ -2287,13 +2349,17 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Put row "a" at a done_at that is definitely "2 days ago" locally.
|
// Put row "a" at a done_at that is definitely "2 days ago" in
|
||||||
// Anchor to start of day then add 12 h so the timestamp is noon UTC
|
// the same local-day spine the query uses. Anchor to the local
|
||||||
// 2 days ago, regardless of when in the day the test runs.
|
// date 2 days ago, then suffix " 12:00:00" so the bucket survives
|
||||||
// Row "b" uses the current moment (today).
|
// 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(
|
sqlx::query(
|
||||||
"UPDATE tasks SET done = 1, \
|
"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'",
|
auto_completed = 0 WHERE id = 'a'",
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
|
|||||||
@@ -451,6 +451,18 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
|||||||
WHERE done_at IS NOT NULL;
|
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.
|
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||||
@@ -600,7 +612,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 13);
|
assert_eq!(count, 14);
|
||||||
|
|
||||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -619,7 +631,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 13);
|
assert_eq!(count, 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ pub struct TranscriptDto {
|
|||||||
pub template: String,
|
pub template: String,
|
||||||
pub language: String,
|
pub language: String,
|
||||||
pub segments_json: String,
|
pub segments_json: String,
|
||||||
|
/// Phase 9 LLM-generated content tags ("topic:...", "intent:...").
|
||||||
|
pub llm_tags: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<TranscriptRow> for TranscriptDto {
|
impl From<TranscriptRow> for TranscriptDto {
|
||||||
@@ -68,6 +70,7 @@ impl From<TranscriptRow> for TranscriptDto {
|
|||||||
template: r.template,
|
template: r.template,
|
||||||
language: r.language,
|
language: r.language,
|
||||||
segments_json: r.segments_json,
|
segments_json: r.segments_json,
|
||||||
|
llm_tags: r.llm_tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,6 +224,11 @@ pub struct UpdateTranscriptMetaRequest {
|
|||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub segments_json: Option<String>,
|
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]
|
#[tauri::command]
|
||||||
@@ -237,6 +245,7 @@ pub async fn update_transcript_meta_cmd(
|
|||||||
patch.template.as_deref(),
|
patch.template.as_deref(),
|
||||||
patch.language.as_deref(),
|
patch.language.as_deref(),
|
||||||
patch.segments_json.as_deref(),
|
patch.segments_json.as_deref(),
|
||||||
|
patch.llm_tags.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|||||||
Reference in New Issue
Block a user