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:
@@ -1,9 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::{AudioSamples, Transcript, TranscriptionOptions};
|
||||
use kon_core::types::{AudioSamples, TranscriptionOptions};
|
||||
|
||||
use crate::local_engine::LocalEngine;
|
||||
use crate::local_engine::{LocalEngine, TimedTranscript};
|
||||
|
||||
/// Runs inference on a blocking thread. Encapsulates all threading concerns.
|
||||
/// Callers never see spawn_blocking — they call this async function.
|
||||
@@ -11,10 +11,12 @@ pub async fn run_inference(
|
||||
engine: Arc<LocalEngine>,
|
||||
audio: AudioSamples,
|
||||
options: TranscriptionOptions,
|
||||
) -> Result<Transcript> {
|
||||
) -> Result<TimedTranscript> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
engine.transcribe_sync(&audio, &options)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| KonError::TranscriptionFailed(format!("Task join error: {e}")))?
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(format!("Task join error: {e}"))
|
||||
})?
|
||||
}
|
||||
|
||||
@@ -3,5 +3,9 @@ pub mod local_engine;
|
||||
pub mod model_manager;
|
||||
|
||||
pub use concurrency::run_inference;
|
||||
pub use local_engine::{load_parakeet, load_whisper, LocalEngine};
|
||||
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
|
||||
pub use local_engine::{
|
||||
load_parakeet, load_whisper, LocalEngine, TimedTranscript,
|
||||
};
|
||||
pub use model_manager::{
|
||||
download, is_downloaded, list_downloaded, model_dir, models_dir,
|
||||
};
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
use transcribe_rs::{SpeechModel, TranscribeOptions, TranscriptionResult};
|
||||
|
||||
use kon_core::error::{KonError, Result};
|
||||
use kon_core::types::{
|
||||
AudioSamples, EngineName, Segment, Transcript, TranscriptionOptions,
|
||||
AudioSamples, EngineName, ModelId, Segment, Transcript,
|
||||
TranscriptionOptions,
|
||||
};
|
||||
|
||||
/// Result of a timed transcription: transcript + inference duration.
|
||||
pub struct TimedTranscript {
|
||||
pub transcript: Transcript,
|
||||
pub inference_ms: u64,
|
||||
}
|
||||
|
||||
/// Wraps any transcribe-rs engine in Kon's SpeechToText trait.
|
||||
/// Encapsulates threading: inference always runs on a blocking thread.
|
||||
/// The rest of the app never imports transcribe-rs directly.
|
||||
pub struct LocalEngine {
|
||||
engine: Mutex<Option<Box<dyn SpeechModel + Send>>>,
|
||||
engine_name: EngineName,
|
||||
loaded_model_id: Mutex<Option<ModelId>>,
|
||||
}
|
||||
|
||||
impl LocalEngine {
|
||||
@@ -21,43 +30,61 @@ impl LocalEngine {
|
||||
Self {
|
||||
engine: Mutex::new(None),
|
||||
engine_name,
|
||||
loaded_model_id: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self, model: Box<dyn SpeechModel + Send>) {
|
||||
let mut guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
pub fn load(&self, model: Box<dyn SpeechModel + Send>, model_id: ModelId) {
|
||||
let mut guard =
|
||||
self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = Some(model);
|
||||
let mut id_guard = self
|
||||
.loaded_model_id
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
*id_guard = Some(model_id);
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &EngineName {
|
||||
&self.engine_name
|
||||
}
|
||||
|
||||
pub fn loaded_model_id(&self) -> Option<ModelId> {
|
||||
let guard = self
|
||||
.loaded_model_id
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
let guard = self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let guard =
|
||||
self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.is_some()
|
||||
}
|
||||
|
||||
/// Run transcription synchronously. Called from within spawn_blocking.
|
||||
/// Run transcription synchronously with timing.
|
||||
/// Called from within spawn_blocking.
|
||||
pub fn transcribe_sync(
|
||||
&self,
|
||||
audio: &AudioSamples,
|
||||
options: &TranscriptionOptions,
|
||||
) -> Result<Transcript> {
|
||||
) -> Result<TimedTranscript> {
|
||||
let mut guard =
|
||||
self.engine.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let engine = guard
|
||||
.as_mut()
|
||||
.ok_or(KonError::EngineNotLoaded)?;
|
||||
let engine =
|
||||
guard.as_mut().ok_or(KonError::EngineNotLoaded)?;
|
||||
|
||||
let opts = TranscribeOptions {
|
||||
language: options.language.clone(),
|
||||
translate: false,
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
let result: TranscriptionResult = engine
|
||||
.transcribe(audio.samples(), &opts)
|
||||
.map_err(|e| KonError::TranscriptionFailed(e.to_string()))?;
|
||||
let inference_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
let segments = result
|
||||
.segments
|
||||
@@ -70,39 +97,48 @@ impl LocalEngine {
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Transcript::new(
|
||||
segments,
|
||||
options
|
||||
.language
|
||||
.clone()
|
||||
.unwrap_or_else(|| "en".to_string()),
|
||||
audio.duration_secs(),
|
||||
))
|
||||
Ok(TimedTranscript {
|
||||
transcript: Transcript::new(
|
||||
segments,
|
||||
options
|
||||
.language
|
||||
.clone()
|
||||
.unwrap_or_else(|| "en".to_string()),
|
||||
audio.duration_secs(),
|
||||
),
|
||||
inference_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a Parakeet model from a directory path.
|
||||
/// The directory should contain model_int8.onnx (+ .onnx_data) and tokenizer.json.
|
||||
pub fn load_parakeet(model_dir: &Path) -> Result<Box<dyn SpeechModel + Send>> {
|
||||
pub fn load_parakeet(
|
||||
model_dir: &Path,
|
||||
) -> Result<Box<dyn SpeechModel + Send>> {
|
||||
use transcribe_rs::onnx::Quantization;
|
||||
let model = transcribe_rs::onnx::parakeet::ParakeetModel::load(
|
||||
model_dir,
|
||||
&Quantization::Int8,
|
||||
)
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(format!("Failed to load Parakeet: {e}"))
|
||||
KonError::TranscriptionFailed(format!(
|
||||
"Failed to load Parakeet: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(Box::new(model))
|
||||
}
|
||||
|
||||
/// Load a Whisper model from a GGML file path.
|
||||
pub fn load_whisper(model_path: &Path) -> Result<Box<dyn SpeechModel + Send>> {
|
||||
let engine = transcribe_rs::whisper_cpp::WhisperEngine::load(model_path)
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(format!(
|
||||
"Failed to load Whisper: {e}"
|
||||
))
|
||||
})?;
|
||||
pub fn load_whisper(
|
||||
model_path: &Path,
|
||||
) -> Result<Box<dyn SpeechModel + Send>> {
|
||||
let engine =
|
||||
transcribe_rs::whisper_cpp::WhisperEngine::load(model_path)
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(format!(
|
||||
"Failed to load Whisper: {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(Box::new(engine))
|
||||
}
|
||||
|
||||
@@ -114,5 +150,6 @@ mod tests {
|
||||
fn engine_reports_not_available_before_loading() {
|
||||
let engine = LocalEngine::new(EngineName::new("test"));
|
||||
assert!(!engine.is_loaded());
|
||||
assert!(engine.loaded_model_id().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user