feat(kon): add full transcript provenance — enriched schema, metadata pipeline, error log

Schema enrichment:
- transcripts table: +engine, +model_id, +inference_ms, +sample_rate, +audio_channels,
  +format_mode, +remove_fillers, +british_english, +anti_hallucination
- New error_log table: context, error_code, message, metadata (JSON)
- 5 indices on frequently queried columns

Metadata pipeline:
- TranscriptMetadata struct in kon-core for full provenance capture
- LocalEngine tracks loaded ModelId, returns TimedTranscript with inference_ms
- std::time::Instant timing around transcribe-rs inference calls
- Tauri transcription commands emit inference_ms in event payloads
- InsertTranscriptParams accepts all provenance fields
- log_error() for structured error logging to SQLite

Every transcript is now fully reproducible from its stored metadata.
23 tests passing, clippy clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-16 21:36:44 +00:00
parent 29ff91d3f6
commit 292f9716ff
9 changed files with 276 additions and 100 deletions

View File

@@ -36,6 +36,15 @@ async fn run_migrations(pool: &SqlitePool) -> Result<()> {
title TEXT,
audio_path TEXT,
duration REAL NOT NULL DEFAULT 0.0,
engine TEXT,
model_id TEXT,
inference_ms INTEGER,
sample_rate INTEGER,
audio_channels INTEGER,
format_mode TEXT,
remove_fillers INTEGER NOT NULL DEFAULT 0,
british_english INTEGER NOT NULL DEFAULT 0,
anti_hallucination INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
@@ -96,29 +105,81 @@ async fn run_migrations(pool: &SqlitePool) -> Result<()> {
.await
.map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS error_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
context TEXT NOT NULL,
error_code TEXT,
message TEXT NOT NULL,
metadata TEXT
)",
)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Migration failed: {e}")))?;
// Indices for frequently queried columns
for ddl in [
"CREATE INDEX IF NOT EXISTS idx_transcripts_created ON transcripts(created_at)",
"CREATE INDEX IF NOT EXISTS idx_segments_transcript ON segments(transcript_id)",
"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)",
] {
sqlx::query(ddl)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Index creation failed: {e}")))?;
}
Ok(())
}
// --- Transcript CRUD ---
/// Parameters for inserting a transcript with full provenance.
pub struct InsertTranscriptParams<'a> {
pub id: &'a str,
pub text: &'a str,
pub source: &'a str,
pub title: Option<&'a str>,
pub audio_path: Option<&'a str>,
pub duration: f64,
pub engine: Option<&'a str>,
pub model_id: Option<&'a str>,
pub inference_ms: Option<i64>,
pub sample_rate: Option<i32>,
pub audio_channels: Option<i32>,
pub format_mode: Option<&'a str>,
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
}
pub async fn insert_transcript(
pool: &SqlitePool,
id: &str,
text: &str,
source: &str,
title: Option<&str>,
audio_path: Option<&str>,
duration: f64,
params: &InsertTranscriptParams<'_>,
) -> Result<()> {
sqlx::query(
"INSERT INTO transcripts (id, text, source, title, audio_path, duration) VALUES (?, ?, ?, ?, ?, ?)",
"INSERT INTO transcripts (id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(text)
.bind(source)
.bind(title)
.bind(audio_path)
.bind(duration)
.bind(params.id)
.bind(params.text)
.bind(params.source)
.bind(params.title)
.bind(params.audio_path)
.bind(params.duration)
.bind(params.engine)
.bind(params.model_id)
.bind(params.inference_ms)
.bind(params.sample_rate)
.bind(params.audio_channels)
.bind(params.format_mode)
.bind(params.remove_fillers)
.bind(params.british_english)
.bind(params.anti_hallucination)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Insert transcript failed: {e}")))?;
@@ -130,45 +191,26 @@ pub async fn get_transcript(
id: &str,
) -> Result<Option<TranscriptRow>> {
let row = sqlx::query(
"SELECT id, text, source, title, audio_path, duration, created_at FROM transcripts WHERE id = ?",
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at FROM transcripts WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Get transcript failed: {e}")))?;
Ok(row.map(|r| TranscriptRow {
id: r.get("id"),
text: r.get("text"),
source: r.get("source"),
title: r.get("title"),
audio_path: r.get("audio_path"),
duration: r.get("duration"),
created_at: r.get("created_at"),
}))
Ok(row.map(|r| transcript_row_from(&r)))
}
pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<TranscriptRow>> {
let rows = sqlx::query(
"SELECT id, text, source, title, audio_path, duration, created_at FROM transcripts ORDER BY created_at DESC LIMIT ?",
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at FROM transcripts ORDER BY created_at DESC LIMIT ?",
)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List transcripts failed: {e}")))?;
Ok(rows
.into_iter()
.map(|r| TranscriptRow {
id: r.get("id"),
text: r.get("text"),
source: r.get("source"),
title: r.get("title"),
audio_path: r.get("audio_path"),
duration: r.get("duration"),
created_at: r.get("created_at"),
})
.collect())
Ok(rows.iter().map(transcript_row_from).collect())
}
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
@@ -274,6 +316,15 @@ pub struct TranscriptRow {
pub title: Option<String>,
pub audio_path: Option<String>,
pub duration: f64,
pub engine: Option<String>,
pub model_id: Option<String>,
pub inference_ms: Option<i64>,
pub sample_rate: Option<i32>,
pub audio_channels: Option<i32>,
pub format_mode: Option<String>,
pub remove_fillers: bool,
pub british_english: bool,
pub anti_hallucination: bool,
pub created_at: String,
}
@@ -290,6 +341,50 @@ pub struct TaskRow {
pub source_transcript_id: Option<String>,
}
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
TranscriptRow {
id: r.get("id"),
text: r.get("text"),
source: r.get("source"),
title: r.get("title"),
audio_path: r.get("audio_path"),
duration: r.get("duration"),
engine: r.get("engine"),
model_id: r.get("model_id"),
inference_ms: r.get("inference_ms"),
sample_rate: r.get("sample_rate"),
audio_channels: r.get("audio_channels"),
format_mode: r.get("format_mode"),
remove_fillers: r.get("remove_fillers"),
british_english: r.get("british_english"),
anti_hallucination: r.get("anti_hallucination"),
created_at: r.get("created_at"),
}
}
// --- Error Logging ---
/// Log a structured error to the error_log table.
pub async fn log_error(
pool: &SqlitePool,
context: &str,
error_code: Option<&str>,
message: &str,
metadata: Option<&str>,
) -> Result<()> {
sqlx::query(
"INSERT INTO error_log (context, error_code, message, metadata) VALUES (?, ?, ?, ?)",
)
.bind(context)
.bind(error_code)
.bind(message)
.bind(metadata)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Error log failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -307,14 +402,38 @@ mod tests {
async fn transcript_crud_roundtrip() {
let pool = test_pool().await;
insert_transcript(&pool, "t1", "Hello world", "microphone", Some("Test"), None, 1.5)
.await
.unwrap();
insert_transcript(
&pool,
&InsertTranscriptParams {
id: "t1",
text: "Hello world",
source: "microphone",
title: Some("Test"),
audio_path: None,
duration: 1.5,
engine: Some("whisper"),
model_id: Some("whisper-tiny-en"),
inference_ms: Some(250),
sample_rate: Some(48000),
audio_channels: Some(1),
format_mode: Some("Clean"),
remove_fillers: true,
british_english: true,
anti_hallucination: false,
},
)
.await
.unwrap();
let t = get_transcript(&pool, "t1").await.unwrap().unwrap();
assert_eq!(t.text, "Hello world");
assert_eq!(t.source, "microphone");
assert_eq!(t.duration, 1.5);
assert_eq!(t.engine.as_deref(), Some("whisper"));
assert_eq!(t.model_id.as_deref(), Some("whisper-tiny-en"));
assert_eq!(t.inference_ms, Some(250));
assert!(t.remove_fillers);
assert!(t.british_english);
let list = list_transcripts(&pool, 10).await.unwrap();
assert_eq!(list.len(), 1);