Land release blocker fixes and workspace cleanup
This commit is contained in:
@@ -225,13 +225,19 @@ mod tests {
|
||||
assert_eq!(LlmPromptPreset::parse("EMAIL"), LlmPromptPreset::Email);
|
||||
assert_eq!(LlmPromptPreset::parse("notes"), LlmPromptPreset::Notes);
|
||||
assert_eq!(LlmPromptPreset::parse("meeting"), LlmPromptPreset::Notes);
|
||||
assert_eq!(LlmPromptPreset::parse("meeting-notes"), LlmPromptPreset::Notes);
|
||||
assert_eq!(
|
||||
LlmPromptPreset::parse("meeting-notes"),
|
||||
LlmPromptPreset::Notes
|
||||
);
|
||||
assert_eq!(LlmPromptPreset::parse("code"), LlmPromptPreset::Code);
|
||||
assert_eq!(LlmPromptPreset::parse("software"), LlmPromptPreset::Code);
|
||||
// Unknown values and explicit default fall back safely.
|
||||
assert_eq!(LlmPromptPreset::parse("default"), LlmPromptPreset::Default);
|
||||
assert_eq!(LlmPromptPreset::parse(""), LlmPromptPreset::Default);
|
||||
assert_eq!(LlmPromptPreset::parse("random-unknown"), LlmPromptPreset::Default);
|
||||
assert_eq!(
|
||||
LlmPromptPreset::parse("random-unknown"),
|
||||
LlmPromptPreset::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -240,7 +246,10 @@ mod tests {
|
||||
// be empty so it composes cleanly with dictionary suffix.
|
||||
assert!(LlmPromptPreset::Default.suffix().is_empty());
|
||||
assert!(LlmPromptPreset::Email.suffix().contains("email"));
|
||||
assert!(LlmPromptPreset::Notes.suffix().to_lowercase().contains("bullet"));
|
||||
assert!(LlmPromptPreset::Notes
|
||||
.suffix()
|
||||
.to_lowercase()
|
||||
.contains("bullet"));
|
||||
assert!(LlmPromptPreset::Code.suffix().contains("technical"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,8 +540,12 @@ mod tests {
|
||||
fn is_hallucination_allows_dialogue_containing_thanks_mid_sentence() {
|
||||
// Exact-match on trail phrases means legitimate dialogue that
|
||||
// mentions "thanks" or "subscribe" is never dropped.
|
||||
assert!(!is_hallucination("Thanks for the heads up on the migration"));
|
||||
assert!(!is_hallucination("Please subscribe to the RSS feed and tell me when it updates"));
|
||||
assert!(!is_hallucination(
|
||||
"Thanks for the heads up on the migration"
|
||||
));
|
||||
assert!(!is_hallucination(
|
||||
"Please subscribe to the RSS feed and tell me when it updates"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -168,7 +168,10 @@ mod tests {
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let out = to_plain_text(&[seg(input)]);
|
||||
assert_eq!(out, expected, "input {input:?} should strip to {expected:?}");
|
||||
assert_eq!(
|
||||
out, expected,
|
||||
"input {input:?} should strip to {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,8 @@ impl WavWriter {
|
||||
};
|
||||
let file = std::fs::File::create(path).map_err(KonError::Io)?;
|
||||
let buffered = BufWriter::new(file);
|
||||
let inner = hound::WavWriter::new(buffered, spec).map_err(|e| {
|
||||
KonError::Io(std::io::Error::other(format!("WAV create failed: {e}")))
|
||||
})?;
|
||||
let inner = hound::WavWriter::new(buffered, spec)
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV create failed: {e}"))))?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
samples_since_flush: 0,
|
||||
@@ -77,9 +76,9 @@ impl WavWriter {
|
||||
/// `Self::DEFAULT_FLUSH_EVERY_SAMPLES` — but may do so at natural
|
||||
/// boundaries (end-of-utterance, UI events) for tighter recovery.
|
||||
pub fn flush(&mut self) -> Result<()> {
|
||||
self.inner.flush().map_err(|e| {
|
||||
KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}")))
|
||||
})?;
|
||||
self.inner
|
||||
.flush()
|
||||
.map_err(|e| KonError::Io(std::io::Error::other(format!("WAV flush failed: {e}"))))?;
|
||||
self.samples_since_flush = 0;
|
||||
Ok(())
|
||||
}
|
||||
@@ -244,8 +243,7 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Write 100 samples (200 bytes at 16-bit).
|
||||
let original =
|
||||
AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
|
||||
let original = AudioSamples::mono_16khz((0..100).map(|i| (i as f32) / 100.0).collect());
|
||||
write_wav(&path, &original).unwrap();
|
||||
|
||||
// Drop the last 10 bytes — 5 samples' worth. hound's iterator
|
||||
|
||||
@@ -1,29 +1,77 @@
|
||||
/// Store an API key in the OS keychain.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Store an API key in Kon's process-local keystore.
|
||||
///
|
||||
/// Stub implementation using environment variables until the `keyring` crate is
|
||||
/// added. Keys are only held in-process and lost on exit.
|
||||
/// Keys are held in memory for the lifetime of the process and are lost on
|
||||
/// exit. This avoids the undefined behaviour of mutating process environment
|
||||
/// variables from arbitrary threads while keeping the public API safe.
|
||||
///
|
||||
/// # Safety note
|
||||
/// `std::env::set_var` is deprecated in Rust 2024 edition and is **not**
|
||||
/// thread-safe — mutating the environment while other threads read it is
|
||||
/// undefined behaviour. This is acceptable during single-threaded app init
|
||||
/// but must not be called from async/multi-threaded contexts.
|
||||
/// `retrieve_api_key` still falls back to `KON_API_KEY_<PROVIDER>` environment
|
||||
/// variables so externally injected secrets continue to work.
|
||||
///
|
||||
/// TODO: Replace with the `keyring` crate (or platform-native credential
|
||||
/// storage) so keys persist across sessions and are accessed safely.
|
||||
#[allow(deprecated)] // set_var deprecated in Rust 2024 edition
|
||||
pub fn store_api_key(provider: &str, key: &str) {
|
||||
// SAFETY: Only safe when called from a single-threaded context (e.g. app
|
||||
// initialisation). See doc comment above.
|
||||
std::env::set_var(format!("KON_API_KEY_{}", provider.to_uppercase()), key);
|
||||
api_key_store()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(provider_env_key(provider), key.to_string());
|
||||
}
|
||||
|
||||
/// Retrieve an API key from the OS keychain.
|
||||
/// Retrieve an API key from Kon's process-local keystore.
|
||||
///
|
||||
/// Stub implementation using environment variables until the `keyring` crate is
|
||||
/// added. Returns `None` if no key has been stored this session.
|
||||
///
|
||||
/// TODO: Replace with the `keyring` crate alongside `store_api_key`.
|
||||
/// Returns a previously stored in-memory key when present, otherwise falls
|
||||
/// back to the read-only `KON_API_KEY_<PROVIDER>` environment variable so
|
||||
/// operator-supplied secrets still work.
|
||||
pub fn retrieve_api_key(provider: &str) -> Option<String> {
|
||||
std::env::var(format!("KON_API_KEY_{}", provider.to_uppercase())).ok()
|
||||
let env_key = provider_env_key(provider);
|
||||
api_key_store()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&env_key)
|
||||
.cloned()
|
||||
.or_else(|| std::env::var(env_key).ok())
|
||||
}
|
||||
|
||||
fn api_key_store() -> &'static Mutex<HashMap<String, String>> {
|
||||
static STORE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||||
STORE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn provider_env_key(provider: &str) -> String {
|
||||
format!("KON_API_KEY_{}", provider.to_uppercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn unique_provider(prefix: &str) -> String {
|
||||
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
|
||||
format!("{prefix}_{}", NEXT_ID.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_key_is_retrievable_without_env_mutation() {
|
||||
let provider = unique_provider("provider");
|
||||
store_api_key(&provider, "secret-token");
|
||||
assert_eq!(
|
||||
retrieve_api_key(&provider),
|
||||
Some("secret-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_do_not_overlap() {
|
||||
let first = unique_provider("first");
|
||||
let second = unique_provider("second");
|
||||
|
||||
store_api_key(&first, "alpha");
|
||||
store_api_key(&second, "beta");
|
||||
|
||||
assert_eq!(retrieve_api_key(&first), Some("alpha".to_string()));
|
||||
assert_eq!(retrieve_api_key(&second), Some("beta".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,10 +349,7 @@ fn is_event_device(path: &Path) -> bool {
|
||||
/// Return true when the device's reported key set includes the combo's
|
||||
/// configured trigger key. A device that reports no keys at all (for
|
||||
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
|
||||
fn device_supports_combo(
|
||||
supported: Option<&AttributeSetRef<Key>>,
|
||||
combo: &HotkeyCombo,
|
||||
) -> bool {
|
||||
fn device_supports_combo(supported: Option<&AttributeSetRef<Key>>, combo: &HotkeyCombo) -> bool {
|
||||
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ pub mod prompts;
|
||||
pub use model_manager::{recommend_tier, LlmModelId, LlmModelInfo};
|
||||
|
||||
const DEFAULT_CONTEXT_TOKENS: u32 = 4096;
|
||||
const MAX_CONTEXT_TOKENS: u32 = 8192;
|
||||
const CONTEXT_RESERVE_TOKENS: u32 = 64;
|
||||
const GENERATION_SEED: u32 = 0;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -26,6 +28,15 @@ pub enum EngineError {
|
||||
NotLoaded,
|
||||
#[error("LLM load failed: {0}")]
|
||||
LoadFailed(String),
|
||||
#[error(
|
||||
"prompt too long: {prompt_tokens} prompt tokens exceed the {available_prompt_tokens}-token prompt budget for an {context_window}-token context with {max_tokens} reserved response tokens"
|
||||
)]
|
||||
PromptTooLong {
|
||||
prompt_tokens: usize,
|
||||
max_tokens: u32,
|
||||
available_prompt_tokens: u32,
|
||||
context_window: u32,
|
||||
},
|
||||
#[error("inference failed: {0}")]
|
||||
Inference(String),
|
||||
#[error("model output not valid JSON: {0}")]
|
||||
@@ -149,7 +160,7 @@ impl LlmEngine {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let n_ctx = context_window_size(prompt_tokens.len(), config.max_tokens);
|
||||
let n_ctx = preflight_context_window(prompt_tokens.len(), config.max_tokens)?;
|
||||
let thread_count = i32::try_from(num_cpus::get().max(1)).unwrap_or(4);
|
||||
let ctx_params = LlamaContextParams::default()
|
||||
.with_n_ctx(Some(
|
||||
@@ -317,8 +328,26 @@ impl LlmEngine {
|
||||
fn context_window_size(prompt_tokens: usize, max_tokens: u32) -> u32 {
|
||||
let required = prompt_tokens
|
||||
.saturating_add(max_tokens as usize)
|
||||
.saturating_add(64);
|
||||
DEFAULT_CONTEXT_TOKENS.max(required.min(8192) as u32)
|
||||
.saturating_add(CONTEXT_RESERVE_TOKENS as usize);
|
||||
DEFAULT_CONTEXT_TOKENS.max(required.min(MAX_CONTEXT_TOKENS as usize) as u32)
|
||||
}
|
||||
|
||||
fn preflight_context_window(prompt_tokens: usize, max_tokens: u32) -> Result<u32, EngineError> {
|
||||
let required = prompt_tokens
|
||||
.saturating_add(max_tokens as usize)
|
||||
.saturating_add(CONTEXT_RESERVE_TOKENS as usize);
|
||||
if required > MAX_CONTEXT_TOKENS as usize {
|
||||
let available_prompt_tokens =
|
||||
MAX_CONTEXT_TOKENS.saturating_sub(max_tokens.saturating_add(CONTEXT_RESERVE_TOKENS));
|
||||
return Err(EngineError::PromptTooLong {
|
||||
prompt_tokens,
|
||||
max_tokens,
|
||||
available_prompt_tokens,
|
||||
context_window: MAX_CONTEXT_TOKENS,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(context_window_size(prompt_tokens, max_tokens))
|
||||
}
|
||||
|
||||
fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option<usize> {
|
||||
@@ -417,4 +446,24 @@ mod tests {
|
||||
let index = first_stop_index(text, &["<|im_end|>".into(), "zzz".into()]);
|
||||
assert_eq!(index, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_preflight_rejects_oversized_prompt_tokens() {
|
||||
let err = preflight_context_window(7_105, 1_024).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
EngineError::PromptTooLong {
|
||||
prompt_tokens: 7_105,
|
||||
max_tokens: 1_024,
|
||||
available_prompt_tokens: 7_104,
|
||||
context_window: MAX_CONTEXT_TOKENS,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_preflight_keeps_prompts_within_budget() {
|
||||
let n_ctx = preflight_context_window(7_104, 1_024).unwrap();
|
||||
assert_eq!(n_ctx, MAX_CONTEXT_TOKENS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,9 @@ async fn list_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap()))
|
||||
Ok(text_content(
|
||||
serde_json::to_string_pretty(&summaries).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_transcript_tool(pool: &SqlitePool, args: Value) -> Result<Value, JsonRpcError> {
|
||||
@@ -288,7 +290,9 @@ async fn search_transcripts_tool(pool: &SqlitePool, args: Value) -> Result<Value
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap()))
|
||||
Ok(text_content(
|
||||
serde_json::to_string_pretty(&summaries).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
|
||||
@@ -311,7 +315,9 @@ async fn list_tasks_tool(pool: &SqlitePool) -> Result<Value, JsonRpcError> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(text_content(serde_json::to_string_pretty(&summaries).unwrap()))
|
||||
Ok(text_content(
|
||||
serde_json::to_string_pretty(&summaries).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
fn text_content(text: String) -> Value {
|
||||
@@ -404,7 +410,10 @@ mod tests {
|
||||
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
let response = handle_message(&pool, request).await.expect("has response");
|
||||
|
||||
let tools = response.result.expect("ok")["tools"].as_array().unwrap().clone();
|
||||
let tools = response.result.expect("ok")["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let names: Vec<String> = tools
|
||||
.iter()
|
||||
.map(|tool| tool["name"].as_str().unwrap().to_string())
|
||||
@@ -426,7 +435,9 @@ mod tests {
|
||||
assert_eq!(resp.jsonrpc, "2.0");
|
||||
assert_eq!(resp.id, Value::Null);
|
||||
assert!(resp.result.is_none());
|
||||
let err = resp.error.expect("parse_error_response must carry an error");
|
||||
let err = resp
|
||||
.error
|
||||
.expect("parse_error_response must carry an error");
|
||||
assert_eq!(err.code, -32700);
|
||||
assert!(err.message.contains("Parse error"));
|
||||
assert!(err.message.contains("expected value"));
|
||||
@@ -449,7 +460,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
kon_storage::migrations::run_migrations(&pool).await.unwrap();
|
||||
kon_storage::migrations::run_migrations(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = handle_message(&pool, request).await.expect("has response");
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -7,10 +7,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let db_path = kon_storage::database_path();
|
||||
eprintln!(
|
||||
"[kon-mcp] opening Kon database at {}",
|
||||
db_path.display()
|
||||
);
|
||||
eprintln!("[kon-mcp] opening Kon database at {}", db_path.display());
|
||||
let pool = kon_storage::init(&db_path).await?;
|
||||
eprintln!("[kon-mcp] ready, waiting for JSON-RPC on stdin");
|
||||
|
||||
|
||||
@@ -62,6 +62,13 @@ pub async fn insert_transcript(
|
||||
pool: &SqlitePool,
|
||||
params: &InsertTranscriptParams<'_>,
|
||||
) -> Result<()> {
|
||||
if !profile_exists(pool, params.profile_id).await? {
|
||||
return Err(KonError::StorageError(format!(
|
||||
"Insert transcript failed: unknown profile id '{}'",
|
||||
params.profile_id
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO transcripts (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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -731,6 +738,12 @@ pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
"Default profile cannot be deleted".into(),
|
||||
));
|
||||
}
|
||||
let transcript_count = transcript_count_for_profile(pool, id).await?;
|
||||
if transcript_count > 0 {
|
||||
return Err(KonError::StorageError(format!(
|
||||
"Cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
|
||||
)));
|
||||
}
|
||||
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
@@ -789,6 +802,23 @@ pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
|
||||
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM profiles WHERE id = ? LIMIT 1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Profile existence check failed: {e}")))?;
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64> {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE profile_id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Profile transcript count failed: {e}")))
|
||||
}
|
||||
|
||||
// --- Error Logging ---
|
||||
|
||||
/// Log a structured error to the `error_log` table.
|
||||
@@ -865,9 +895,14 @@ mod tests {
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
run_migrations(&pool).await.unwrap();
|
||||
pool
|
||||
}
|
||||
@@ -1047,11 +1082,19 @@ mod tests {
|
||||
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s1", "Final test", "p1").await.unwrap();
|
||||
insert_subtask(&pool, "s2", "Tag release", "p1").await.unwrap();
|
||||
insert_subtask(&pool, "s1", "Final test", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s2", "Tag release", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
complete_subtask_and_check_parent(&pool, "s1").await.unwrap();
|
||||
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
|
||||
complete_subtask_and_check_parent(&pool, "s1")
|
||||
.await
|
||||
.unwrap();
|
||||
complete_subtask_and_check_parent(&pool, "s2")
|
||||
.await
|
||||
.unwrap();
|
||||
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
||||
assert!(parent.done);
|
||||
|
||||
@@ -1353,6 +1396,72 @@ mod tests {
|
||||
assert_eq!(profiles.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_transcript_rejects_unknown_profile_id() {
|
||||
let pool = test_pool().await;
|
||||
let err = insert_transcript(
|
||||
&pool,
|
||||
&InsertTranscriptParams {
|
||||
id: "bad-profile",
|
||||
text: "Hello",
|
||||
source: "microphone",
|
||||
profile_id: "profile-missing",
|
||||
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
|
||||
.expect_err("unknown profile id must be rejected");
|
||||
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("unknown profile id"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_profile_rejects_when_transcripts_reference_it() {
|
||||
let pool = test_pool().await;
|
||||
let profile = create_profile(&pool, "Referenced", "").await.unwrap();
|
||||
insert_transcript(
|
||||
&pool,
|
||||
&InsertTranscriptParams {
|
||||
id: "referenced-transcript",
|
||||
text: "Hello",
|
||||
source: "microphone",
|
||||
profile_id: &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 err = delete_profile(&pool, &profile.id)
|
||||
.await
|
||||
.expect_err("profile with transcript references must not delete");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("reassign transcripts first"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_profile_terms_happy_path() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
@@ -215,6 +215,125 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
ON transcripts(profile_id);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
9,
|
||||
"transcript_profile_fk",
|
||||
r#"
|
||||
INSERT OR IGNORE INTO profiles (id, name, initial_prompt, created_at)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', '', datetime('now'));
|
||||
|
||||
DROP TRIGGER IF EXISTS transcripts_ai;
|
||||
DROP TRIGGER IF EXISTS transcripts_ad;
|
||||
DROP TRIGGER IF EXISTS transcripts_au;
|
||||
DROP TABLE IF EXISTS transcripts_fts;
|
||||
DROP INDEX IF EXISTS idx_segments_transcript;
|
||||
DROP INDEX IF EXISTS idx_transcripts_created;
|
||||
DROP INDEX IF EXISTS idx_transcripts_profile_id;
|
||||
|
||||
ALTER TABLE segments RENAME TO segments_old;
|
||||
ALTER TABLE transcripts RENAME TO transcripts_old;
|
||||
|
||||
CREATE TABLE transcripts (
|
||||
id TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'microphone',
|
||||
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')),
|
||||
starred INTEGER NOT NULL DEFAULT 0,
|
||||
manual_tags TEXT NOT NULL DEFAULT '',
|
||||
template TEXT NOT NULL DEFAULT '',
|
||||
language TEXT NOT NULL DEFAULT '',
|
||||
segments_json TEXT NOT NULL DEFAULT '',
|
||||
profile_id TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'
|
||||
REFERENCES profiles(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transcripts_created
|
||||
ON transcripts(created_at);
|
||||
CREATE INDEX idx_transcripts_profile_id
|
||||
ON transcripts(profile_id);
|
||||
|
||||
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, created_at,
|
||||
starred, manual_tags, template, language, segments_json, profile_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,
|
||||
starred, manual_tags, template, language, segments_json,
|
||||
CASE
|
||||
WHEN profile_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = transcripts_old.profile_id
|
||||
)
|
||||
THEN profile_id
|
||||
ELSE '00000000-0000-0000-0000-000000000001'
|
||||
END
|
||||
FROM transcripts_old;
|
||||
|
||||
CREATE TABLE segments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transcript_id TEXT NOT NULL REFERENCES transcripts(id) ON DELETE CASCADE,
|
||||
start_time REAL NOT NULL,
|
||||
end_time REAL NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX idx_segments_transcript
|
||||
ON segments(transcript_id);
|
||||
|
||||
INSERT INTO segments (id, transcript_id, start_time, end_time, text)
|
||||
SELECT id, transcript_id, start_time, end_time, text
|
||||
FROM segments_old;
|
||||
|
||||
DROP TABLE segments_old;
|
||||
DROP TABLE transcripts_old;
|
||||
|
||||
CREATE VIRTUAL TABLE transcripts_fts USING fts5(
|
||||
text,
|
||||
title,
|
||||
content='transcripts',
|
||||
content_rowid='rowid',
|
||||
tokenize='porter unicode61 remove_diacritics 2'
|
||||
);
|
||||
|
||||
CREATE TRIGGER transcripts_ai AFTER INSERT ON transcripts BEGIN
|
||||
INSERT INTO transcripts_fts(rowid, text, title)
|
||||
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
|
||||
END;
|
||||
|
||||
CREATE TRIGGER transcripts_ad AFTER DELETE ON transcripts BEGIN
|
||||
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
|
||||
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
|
||||
END;
|
||||
|
||||
CREATE TRIGGER transcripts_au AFTER UPDATE ON transcripts BEGIN
|
||||
INSERT INTO transcripts_fts(transcripts_fts, rowid, text, title)
|
||||
VALUES ('delete', old.rowid, old.text, COALESCE(old.title, ''));
|
||||
INSERT INTO transcripts_fts(rowid, text, title)
|
||||
VALUES (new.rowid, new.text, COALESCE(new.title, ''));
|
||||
END;
|
||||
|
||||
INSERT INTO transcripts_fts(rowid, text, title)
|
||||
SELECT rowid, text, COALESCE(title, '')
|
||||
FROM transcripts;
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -281,10 +400,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||
/// implicitly commits (`VACUUM`, `REINDEX`, `ATTACH`), it must be split
|
||||
/// out into its own non-transactional migration — reviewer's job to
|
||||
/// flag.
|
||||
async fn run_migrations_slice(
|
||||
pool: &SqlitePool,
|
||||
migrations: &[(i64, &str, &str)],
|
||||
) -> Result<()> {
|
||||
async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)]) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
@@ -344,12 +460,22 @@ mod tests {
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::Row;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_run_on_empty_db() {
|
||||
async fn fk_test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
.expect("pool");
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("enable foreign keys");
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_run_on_empty_db() {
|
||||
let pool = fk_test_pool().await;
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
|
||||
@@ -357,7 +483,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 8);
|
||||
assert_eq!(count, 9);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -367,10 +493,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrations_idempotent() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
let pool = fk_test_pool().await;
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
run_migrations(&pool).await.unwrap();
|
||||
@@ -379,7 +502,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 8);
|
||||
assert_eq!(count, 9);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -440,11 +563,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_transcript_profile_provenance_adds_profile_id() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("pool");
|
||||
let pool = fk_test_pool().await;
|
||||
run_migrations(&pool).await.expect("migrate");
|
||||
|
||||
let info = sqlx::query("PRAGMA table_info(transcripts)")
|
||||
@@ -459,11 +578,108 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parent_task_id_cascade_delete() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
async fn migration_v9_reconciles_orphaned_transcript_profiles_and_adds_fk() {
|
||||
let pool = fk_test_pool().await;
|
||||
run_migrations_up_to(&pool, 8).await.expect("migrate to v8");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, name, initial_prompt, created_at)
|
||||
VALUES ('profile-valid', 'Valid', '', datetime('now'))",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed valid profile");
|
||||
|
||||
sqlx::query(
|
||||
"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, created_at,
|
||||
starred, manual_tags, template, language, segments_json, profile_id
|
||||
) VALUES (
|
||||
't-orphan', 'orphan body', 'microphone', 'Orphan', NULL, 0.0, NULL, NULL,
|
||||
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
|
||||
0, '', '', '', '', 'profile-missing'
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed orphan transcript");
|
||||
sqlx::query(
|
||||
"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, created_at,
|
||||
starred, manual_tags, template, language, segments_json, profile_id
|
||||
) VALUES (
|
||||
't-valid', 'valid body', 'microphone', 'Valid', NULL, 0.0, NULL, NULL,
|
||||
NULL, NULL, NULL, NULL, 0, 0, 0, datetime('now'),
|
||||
0, '', '', '', '', 'profile-valid'
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed valid transcript");
|
||||
sqlx::query(
|
||||
"INSERT INTO segments (transcript_id, start_time, end_time, text)
|
||||
VALUES ('t-orphan', 0.0, 1.0, 'segment')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("seed segment");
|
||||
|
||||
run_migrations(&pool).await.expect("migrate to v9");
|
||||
|
||||
let orphan_profile: String =
|
||||
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-orphan'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read healed orphan");
|
||||
assert_eq!(orphan_profile, crate::DEFAULT_PROFILE_ID);
|
||||
|
||||
let valid_profile: String =
|
||||
sqlx::query_scalar("SELECT profile_id FROM transcripts WHERE id = 't-valid'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read preserved profile");
|
||||
assert_eq!(valid_profile, "profile-valid");
|
||||
|
||||
let segment_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM segments WHERE transcript_id = 't-orphan'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read migrated segments");
|
||||
assert_eq!(segment_count, 1, "segments must survive transcript rebuild");
|
||||
|
||||
let fk_rows = sqlx::query("PRAGMA foreign_key_list(transcripts)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
.expect("read transcript foreign keys");
|
||||
assert!(
|
||||
fk_rows.iter().any(|row| {
|
||||
row.get::<String, _>("table") == "profiles"
|
||||
&& row.get::<String, _>("from") == "profile_id"
|
||||
&& row.get::<String, _>("to") == "id"
|
||||
&& row.get::<String, _>("on_delete") == "RESTRICT"
|
||||
}),
|
||||
"transcripts.profile_id must reference profiles(id) with ON DELETE RESTRICT"
|
||||
);
|
||||
|
||||
let fts_hits: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM transcripts_fts WHERE transcripts_fts MATCH 'orphan'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("query rebuilt fts");
|
||||
assert_eq!(
|
||||
fts_hits, 1,
|
||||
"fts index must be rebuilt for existing transcripts"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parent_task_id_cascade_delete() {
|
||||
let pool = fk_test_pool().await;
|
||||
|
||||
run_migrations(&pool).await.unwrap();
|
||||
|
||||
@@ -656,7 +872,7 @@ mod tests {
|
||||
run_migrations(&pool).await.expect("baseline migrate");
|
||||
|
||||
const POISON: &[(i64, &str, &str)] = &[(
|
||||
9,
|
||||
10,
|
||||
"rb-02 atomicity poison",
|
||||
r#"
|
||||
CREATE TABLE poison_marker (id INTEGER PRIMARY KEY);
|
||||
@@ -680,15 +896,14 @@ mod tests {
|
||||
"poison_marker must not exist; got: {marker:?}"
|
||||
);
|
||||
|
||||
// `schema_version` must not include v9 — version insert is part
|
||||
// `schema_version` must not include v10 — version insert is part
|
||||
// of the same transaction that rolled back.
|
||||
let max: i64 =
|
||||
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
let max: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("read schema_version");
|
||||
assert_eq!(
|
||||
max, 8,
|
||||
max, 9,
|
||||
"schema_version must not advance past the failed migration"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ pub mod transcriber;
|
||||
pub mod whisper_rs_backend;
|
||||
|
||||
pub use concurrency::run_inference;
|
||||
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
|
||||
#[cfg(feature = "whisper")]
|
||||
pub use local_engine::load_whisper;
|
||||
pub use local_engine::{load_parakeet, LocalEngine, SpeechModelAdapter, TimedTranscript};
|
||||
pub use model_manager::{download, is_downloaded, list_downloaded, model_dir, models_dir};
|
||||
pub use streaming::{
|
||||
sample_index_for_seconds, trim_buffer_to_commit_point, CommitDecision, CommitPolicy,
|
||||
LocalAgreement, RmsVadChunker, Token, VadChunk, VadChunker,
|
||||
};
|
||||
pub use transcriber::{Transcriber, TranscriberCapabilities};
|
||||
pub use transcribe_rs::SpeechModel;
|
||||
pub use transcriber::{Transcriber, TranscriberCapabilities};
|
||||
|
||||
@@ -557,7 +557,10 @@ mod tests {
|
||||
.expect_err("mismatched sha must fail");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("SHA256 mismatch"), "unexpected error: {msg}");
|
||||
assert!(!dest.exists(), ".part → dest rename must not run on mismatch");
|
||||
assert!(
|
||||
!dest.exists(),
|
||||
".part → dest rename must not run on mismatch"
|
||||
);
|
||||
let part = dest.with_extension("bin.part");
|
||||
assert!(!part.exists(), "failed hash must clean up the .part file");
|
||||
}
|
||||
|
||||
@@ -108,11 +108,7 @@ impl LocalAgreement {
|
||||
|
||||
// Can't commit anything until we have n passes in hand.
|
||||
if self.history.len() < self.n {
|
||||
let tentative = self
|
||||
.history
|
||||
.back()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let tentative = self.history.back().cloned().unwrap_or_default();
|
||||
return CommitDecision {
|
||||
newly_committed: Vec::new(),
|
||||
tentative,
|
||||
|
||||
@@ -121,7 +121,7 @@ impl RmsVadChunker {
|
||||
pending_onset_frames: 0,
|
||||
onset_buffer: Vec::new(),
|
||||
next_sample_index: 0,
|
||||
active_chunk_start: 0,
|
||||
active_chunk_start: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,10 +552,7 @@ mod tests {
|
||||
let mut c = RmsVadChunker::new();
|
||||
assert!(c.flush().is_empty());
|
||||
let _ = c.push(&constant_signal(16_000, 0.0));
|
||||
assert!(
|
||||
c.flush().is_empty(),
|
||||
"flushing pure silence emits nothing"
|
||||
);
|
||||
assert!(c.flush().is_empty(), "flushing pure silence emits nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -62,10 +62,9 @@ impl Transcriber for WhisperRsBackend {
|
||||
"WhisperRsBackend::transcribe_sync entering"
|
||||
);
|
||||
|
||||
let mut state = self
|
||||
.ctx
|
||||
.create_state()
|
||||
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string()))?;
|
||||
let mut state = self.ctx.create_state().map_err(|e| {
|
||||
KonError::TranscriptionFailed(WhisperBackendError::State(e.to_string()).to_string())
|
||||
})?;
|
||||
|
||||
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||
if let Some(lang) = options.language.as_deref() {
|
||||
@@ -83,9 +82,11 @@ impl Transcriber for WhisperRsBackend {
|
||||
params.set_print_progress(false);
|
||||
params.set_print_realtime(false);
|
||||
|
||||
state
|
||||
.full(params, samples)
|
||||
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?;
|
||||
state.full(params, samples).map_err(|e| {
|
||||
KonError::TranscriptionFailed(
|
||||
WhisperBackendError::Transcribe(e.to_string()).to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let n = state.full_n_segments();
|
||||
|
||||
@@ -96,7 +97,11 @@ impl Transcriber for WhisperRsBackend {
|
||||
};
|
||||
let text = seg
|
||||
.to_str()
|
||||
.map_err(|e| KonError::TranscriptionFailed(WhisperBackendError::Transcribe(e.to_string()).to_string()))?
|
||||
.map_err(|e| {
|
||||
KonError::TranscriptionFailed(
|
||||
WhisperBackendError::Transcribe(e.to_string()).to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string();
|
||||
// whisper-rs timestamps are centiseconds (10ms units). Convert to seconds (f64).
|
||||
let start = seg.start_timestamp() as f64 * 0.01;
|
||||
|
||||
Reference in New Issue
Block a user